UI修改
This commit is contained in:
@@ -1,280 +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 reportUrl=await moduleUrl('../src/lib/analytics/report-fields.ts');
|
||||
const {TRACKER_APP_ID,COMMON_KEYS,SDK_COMMON_KEYS,base64JSON,trackingPage}=await import(trackingUrl);
|
||||
const authUrl='data:text/javascript,export function authMetadata(){return {channel:"paid_1050",devID:"business-device",deviceId:"business-device"}}';
|
||||
const userAgentUrl=await moduleUrl('../src/lib/api/user-agent.ts');
|
||||
const analyticsUrl=await moduleUrl('../src/lib/client/analytics.ts',code=>code.replace("'../tracking'",JSON.stringify(trackingUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../api/user-agent'",JSON.stringify(userAgentUrl)).replace("'../analytics/report-fields'",JSON.stringify(reportUrl)));
|
||||
const {SiteTracker}=await import(analyticsUrl);
|
||||
const {trackingHeaders}=await import(await moduleUrl('../src/lib/api/tracking-headers.ts',code=>code.replace("'../tracking'",JSON.stringify(trackingUrl))));
|
||||
const flush=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
const common=()=>({app_id:TRACKER_APP_ID,uid:'',channel:'paid_1050',sid:'sid-raw',client_ts:Math.floor(Date.now()/1000),device:'PC',device_id:'sdk-device',device_brand:'',device_model:'',device_fingerprint:'raw-fingerprint',fp_version:'3.0.1',user_agent:'test UA',sdk_version:'1.2.0',app_version:'1.0.0'});
|
||||
|
||||
class Element extends EventTarget {
|
||||
dataset={};hidden=false;attributes=new Map();
|
||||
getClientRects(){return this.hidden?[]:[{}];}
|
||||
closest(selector){
|
||||
if(selector.includes('[hidden]'))return this.hidden||this.closedDialog?this:null;
|
||||
if(selector==='[data-track-item]'&&this.hasAttribute('data-track-item'))return this;
|
||||
if(selector==='[data-track-ad="external"]'&&this.getAttribute('data-track-ad')==='external')return this;
|
||||
if(selector.includes('a[data-app-download]')&&this.hasAttribute('data-app-download'))return this;
|
||||
if(selector.includes('a[data-track-navigation]')&&this.dataset.trackNavigation)return this;
|
||||
return this.parents?.[selector]||null;
|
||||
}
|
||||
getAttribute(name){return this.attributes.get(name)||null;}
|
||||
hasAttribute(name){return this.attributes.has(name);}
|
||||
}
|
||||
class Video extends Element {currentTime=0;duration=100;playbackRate=1.25;paused=false;ended=false;seeking=false;}
|
||||
function fixture(){
|
||||
const saved=new Map(),events=[],calls=[],nodes=[],results=[],diagnostics=[],observed=new Set(),consoleCalls=[];
|
||||
let clock=1000,video,intersection,mutation,modal=null;
|
||||
const fields=common();
|
||||
const sdk={init(options){calls.push(options);},track:new Proxy({}, {get:(_,method)=>async payload=>{events.push({method,payload});const result=JSON.parse(JSON.stringify(await sdkSandbox.WebSDK.track[method](payload)));results.push({method,...result});return result;}}),getCommonFields:async()=>({...fields}),setUid(uid){fields.uid=uid;},setChannel(channel){fields.channel=channel;},notifyPageLoad(){calls.push('route');},flush:async()=>{calls.push('flush');}};
|
||||
const win=new EventTarget();win.WebSDK=sdk;win.console={warn:(...args)=>consoleCalls.push(args)};
|
||||
const document=new EventTarget();Object.assign(document,{referrer:'https://search.example/',documentElement:new Element(),head:{append(){}},querySelector:selector=>selector==='dialog:modal'?modal:video||null,querySelectorAll:()=>nodes});
|
||||
document.addEventListener('tracker:diagnostic',event=>diagnostics.push(event.detail));
|
||||
const values={window:win,document,location:{pathname:'/',search:'',origin:'https://site.example',href:'https://site.example/'},performance:{now:()=>clock,getEntriesByType:()=>[{duration:42}]},Element,HTMLVideoElement:Video,IntersectionObserver:class{constructor(callback){intersection=callback;}observe(node){observed.add(node);}unobserve(node){observed.delete(node);}disconnect(){observed.clear();}},MutationObserver:class{constructor(callback){mutation=callback;}observe(){}},innerWidth:1000,innerHeight:800,screen:{width:1000,height:800}};
|
||||
for(const [key,value] of Object.entries(values)){saved.set(key,Object.getOwnPropertyDescriptor(globalThis,key));Object.defineProperty(globalThis,key,{configurable:true,writable:true,value});}
|
||||
const tracker=new SiteTracker();win.tracker=tracker;
|
||||
return {tracker,events,calls,fields,nodes,win,document,results,diagnostics,observed,sdk,consoleCalls,location:values.location,mutate:()=>mutation([]),setModal:value=>{modal=value;},setClock:value=>{clock=value;},setVideo:value=>{video=value;},intersect:node=>intersection([{target:node,isIntersecting:true}]),close(){for(const [key,descriptor] of saved)if(descriptor)Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}};
|
||||
}
|
||||
const drama={id:23456,title:'短剧标题',totalEpisode:12,themeIds:[8],themeNames:['都市'],tagIds:[9],tagNames:['爱情']};
|
||||
const chapters=[{id:90001,currentEpisode:1,mediaTime:100},{id:90002,currentEpisode:2,mediaTime:100}];
|
||||
|
||||
function advertisement(placement='inline'){
|
||||
const node=new Element();node.attributes.set('data-track-ad','external');
|
||||
node.dataset={adId:'same-code',adSlotKey:'slot-3',adSlotName:'推荐应用',adType:'icon',adClickable:'1',adPlacement:placement};
|
||||
return node;
|
||||
}
|
||||
|
||||
test('real SDK accepts content exposures, clicks, search and navigation fields including the root parent id',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
const item=new Element();item.attributes.set('data-track-item','');item.dataset={itemId:'23456',itemType:'short_drama',itemName:'剧名'};
|
||||
f.intersect(item);f.tracker.click({target:item,clientX:10,clientY:10});
|
||||
f.tracker.search('短剧',28,1);
|
||||
const nav=new Element();nav.dataset.trackNavigation='tg_group';nav.textContent='官方TG群';nav.href='https://t.me/example';nav.attributes.set('href',nav.href);
|
||||
f.tracker.click({target:nav,clientX:10,clientY:10});
|
||||
await flush();
|
||||
for(const method of ['appPageView','appPageitemExposure','appPageitemClick','keywordSearch','navigation','pageClick'])assert.ok(f.results.some(result=>result.method===method&&result.ok),method);
|
||||
assert.ok(f.events.filter(e=>e.method.startsWith('appPageitem')).every(e=>e.payload.parent_item_id==='0'));
|
||||
assert.deepEqual(f.results.filter(result=>!result.ok),[]);assert.deepEqual(f.diagnostics,[]);
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('home, large-popup and grid-popup advertisements report independently without duplicating each placement',async()=>{
|
||||
const f=fixture();try{
|
||||
const nodes=['inline','startup-ad','startup-grid'].map(advertisement);
|
||||
// Popup events can arrive before the asynchronous SDK configuration.
|
||||
for(const node of nodes){f.tracker.externalAd(node,'show');f.tracker.externalAd(node,'show');f.tracker.externalAd(node,'click');}
|
||||
assert.equal(f.events.length,0);
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
assert.equal(f.events.filter(e=>e.method==='adImpression').length,3);assert.equal(f.events.filter(e=>e.method==='adClick').length,3);
|
||||
assert.ok(f.events.filter(e=>e.method==='adImpression').every(e=>e.payload.ad_slot_key==='slot-3'),'retain API advertisement slot codes');
|
||||
assert.deepEqual(f.results.filter(result=>!result.ok),[]);
|
||||
const count=f.events.length;f.tracker.externalAd(nodes[1],'close');assert.equal(f.events.length,count,'closing is not an ad click');
|
||||
for(const node of nodes.slice(1)){f.tracker.advertising(node,'close');f.tracker.advertising(node,'close');}
|
||||
const popup=f.events.filter(e=>e.method==='advertising');
|
||||
assert.equal(popup.filter(e=>e.payload.event_type==='show').length,2);
|
||||
assert.equal(popup.filter(e=>e.payload.event_type==='click').length,2);
|
||||
assert.equal(popup.filter(e=>e.payload.event_type==='close').length,2);
|
||||
await flush();assert.deepEqual(f.results.filter(result=>!result.ok),[]);
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('closed dialogs and obscured background content are observed again when the modal closes',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
const background=advertisement(),grid=advertisement('startup-grid'),offscreen=advertisement('offscreen');offscreen.dataset.adId='offscreen';
|
||||
f.nodes.push(background,grid,offscreen);grid.closedDialog=true;f.mutate();
|
||||
const modal={contains:node=>node===grid||node===offscreen};f.setModal(modal);grid.closedDialog=false;f.mutate();
|
||||
assert.equal(f.observed.has(background),false);assert.ok(f.observed.has(grid));
|
||||
f.intersect(background);assert.equal(f.events.filter(e=>e.method==='adImpression').length,0);
|
||||
f.intersect(grid);assert.equal(f.events.filter(e=>e.method==='adImpression').length,1);
|
||||
assert.equal(f.events.some(e=>e.payload.ad_id==='offscreen'),false,'a scrollable grid must only expose intersecting tiles');
|
||||
f.intersect(offscreen);assert.equal(f.events.filter(e=>e.method==='adImpression').length,2);
|
||||
grid.closedDialog=true;offscreen.closedDialog=true;f.setModal(null);f.mutate();
|
||||
assert.ok(f.observed.has(background));f.intersect(background);assert.equal(f.events.filter(e=>e.method==='adImpression').length,3);
|
||||
f.document.hidden=true;const hidden=advertisement('hidden');hidden.dataset.adId='hidden';const before=f.events.length;
|
||||
f.tracker.externalAd(hidden,'show');f.intersect(hidden);assert.equal(f.events.length,before);
|
||||
f.nodes.push(hidden);f.document.hidden=false;f.document.dispatchEvent(new Event('visibilitychange'));
|
||||
assert.ok(f.observed.has(hidden));f.intersect(hidden);assert.equal(f.events.filter(e=>e.method==='adImpression').length,4);
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('SDK validation errors and denied event codes produce sanitized diagnostics instead of disappearing silently',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
const broken=new Element();broken.attributes.set('data-track-item','');broken.dataset={itemId:'7',itemType:'short_drama'};
|
||||
f.intersect(broken);await flush();assert.equal(f.diagnostics[0].reason,'event_validation_failed');assert.ok(f.diagnostics[0].missingFields.includes('item_name'));
|
||||
f.sdk.track={adImpression:async()=>({ok:false,reason:'event_code_not_allowed',encryptedConfig:'never-log-cipher',payload:{uid:'never-log-uid'}})};
|
||||
f.tracker.externalAd(advertisement(),'show');await flush();
|
||||
assert.equal(f.diagnostics.at(-1).reason,'event_code_not_allowed');assert.ok(!JSON.stringify(f.diagnostics).includes('never-log'));
|
||||
f.tracker.externalAd(advertisement('next'),'show');await flush();assert.equal(f.diagnostics.filter(d=>d.reason==='event_code_not_allowed').length,1);
|
||||
assert.deepEqual(f.consoleCalls,[]);assert.deepEqual(sdkLogs,[]);
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('bootstrap SDK configuration requests bypass SDK readiness, and delayed config preserves more than 200 queued events',async()=>{
|
||||
const f=fixture();try{
|
||||
f.sdk.getCommonFields=()=>{throw Error('configuration must not depend on SDK fields');};
|
||||
assert.deepEqual(await f.tracker.requestHeaders('config/info','GET',{}),{});
|
||||
for(let n=0;n<220;n++){const node=advertisement();node.dataset.adId='ad-'+n;f.tracker.externalAd(node,'show');}
|
||||
f.tracker.externalAd(advertisement('startup-grid'),'click');
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
assert.equal(f.events.filter(e=>e.method==='adImpression').length,221);assert.equal(f.events.filter(e=>e.method==='adClick').length,1);
|
||||
assert.deepEqual(f.results.filter(result=>!result.ok),[]);
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('all playback behavior and episode status payloads pass the real SDK validator',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
f.tracker.setDrama(drama,chapters[0],chapters);
|
||||
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
|
||||
for(const behavior of ['ep_play','ep_pause','ep_forward','ep_rewind','ep_complete','ep_share','ep_failed'])f.tracker.dramaEvent(behavior,video);
|
||||
for(const behavior of ['ep_previous','ep_next','ep_switch','ep_unlock','ep_auto_play'])f.tracker.dramaStatusChange(chapters[1],behavior);
|
||||
await flush();assert.deepEqual(f.results.filter(result=>!result.ok),[]);assert.deepEqual(f.diagnostics,[]);
|
||||
assert.ok(f.results.some(result=>result.method==='dramaEvent'));assert.ok(f.results.some(result=>result.method==='dramaStatusChange'));
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('SDK loading can recover after the first script was unavailable and a failed init leaves queued events retryable',async()=>{
|
||||
const f=fixture();try{
|
||||
delete f.win.WebSDK;let loaded=0;
|
||||
f.document.createElement=()=>({remove(){}});
|
||||
f.document.head.append=script=>{loaded++;queueMicrotask(()=>{f.win.WebSDK=f.sdk;script.onload();});};
|
||||
const initialize=f.sdk.init;let fail=true;
|
||||
f.sdk.init=options=>{if(fail)throw Error('private-config-must-not-appear');return initialize(options);};
|
||||
f.tracker.externalAd(advertisement('startup-ad'),'show');f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
assert.equal(loaded,1);assert.equal(f.events.length,0);assert.equal(f.diagnostics[0].reason,'sdk_initialization_failed');
|
||||
fail=false;f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
assert.equal(f.events.filter(e=>e.method==='appPageView').length,1);assert.equal(f.events.filter(e=>e.method==='adImpression').length,1);
|
||||
assert.ok(!JSON.stringify(f.diagnostics).includes('private-config'));
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('SDK initializes once from dynamic config, queues the first PV and tracks each native route once',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({});await flush();assert.equal(f.calls.length,0);assert.equal(f.events.length,0);
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
assert.equal(f.calls[0].appId,TRACKER_APP_ID);assert.equal(f.calls[0].encryptedConfig,'configured-cipher');assert.deepEqual(f.calls[0].disableAutoEvents,['app_page_view','page_click']);
|
||||
assert.equal(f.events.filter(e=>e.method==='appPageView').length,1);
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});f.tracker.pageView();assert.equal(f.events.length,1);
|
||||
f.location.pathname='/rank/new/page/2/';f.tracker.pageView();
|
||||
const pv=f.events.at(-1);assert.equal(pv.payload.page_key,'rank_new');assert.equal(pv.payload.referrer_page_key,'home');assert.equal(f.calls.filter(c=>c==='route').length,1);
|
||||
f.tracker.syncUser({userId:123456});assert.equal(f.fields.uid,'123456');f.tracker.syncUser(null);assert.equal(f.fields.uid,'');
|
||||
for(const path of ['/navigation/','/info/about-us/','/member/settings/','/topics/3/','/video/8/','/richtext/关于我们/','/richetext/关于我们/'])assert.notEqual(trackingPage(path).key,'internal_other');
|
||||
assert.equal(trackingPage('/richtext/关于我们/').key,'rich_text');
|
||||
const count=f.events.filter(e=>e.method==='appPageView').length;
|
||||
f.tracker.resumePage();assert.equal(f.events.filter(e=>e.method==='appPageView').length,count+1);
|
||||
f.tracker.pageView();assert.equal(f.events.filter(e=>e.method==='appPageView').length,count+1,'ordinary bindings must not duplicate the restored page view');
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('ad visibility and clicks deduplicate across DOM replacements, resetting only on a new page',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
const ad=new Element();ad.attributes.set('data-track-ad','external');ad.dataset={adId:'advertise-code',adSlotKey:'slot',adSlotName:'广告位',adType:'banner',adClickable:'1'};
|
||||
ad.hidden=true;f.intersect(ad);assert.equal(f.events.filter(e=>e.method==='adImpression').length,0);
|
||||
ad.hidden=false;f.intersect(ad);f.tracker.externalAd(ad,'show');f.tracker.externalAd(ad,'click');
|
||||
assert.equal(f.events.filter(e=>e.method==='adImpression').length,1);assert.equal(f.events.filter(e=>e.method==='adClick').length,1);
|
||||
f.location.pathname='/shorts/';f.tracker.pageView();f.tracker.externalAd(ad,'show');assert.equal(f.events.filter(e=>e.method==='adImpression').length,2);
|
||||
f.location.pathname='/';f.tracker.pageView();f.tracker.externalAd(ad,'show');assert.equal(f.events.filter(e=>e.method==='adImpression').length,2,'returning to a previously seen tab must not repeat its ads');
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('play durations exclude buffering and preview, speed is scaled, and seek/completion/status use named events',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
f.tracker.setDrama(drama,chapters[0],chapters);
|
||||
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
|
||||
const emit=type=>f.tracker.videoEvent({type,target:video});
|
||||
emit('play');emit('playing');f.setClock(5000);emit('waiting');f.setClock(15000);emit('playing');f.setClock(18000);video.paused=true;video.currentTime=30;emit('pause');
|
||||
const pause=f.events.filter(e=>e.payload.play_behavior_key==='ep_pause').at(-1);assert.equal(pause.payload.play_duration,7);assert.equal(pause.payload.play_speed,125);assert.equal(pause.payload.play_progress,30);
|
||||
const count=f.events.length;mode.dataset.playMode='preview';emit('play');emit('playing');assert.equal(f.events.length,count);mode.dataset.playMode='main';
|
||||
video.currentTime=20;emit('timeupdate');emit('seeking');video.currentTime=60;emit('seeked');assert.equal(f.events.at(-1).payload.play_behavior_key,'ep_forward');
|
||||
video.currentTime=100;video.ended=true;emit('ended');assert.equal(f.events.at(-2).payload.play_behavior_key,'ep_complete');assert.equal(f.events.at(-1).payload.play_behavior_key,'ep_play');assert.equal(f.events.at(-1).payload.play_progress,100);
|
||||
f.tracker.dramaStatusChange(chapters[1]);assert.equal(f.events.at(-1).payload.drama_behavior_key,'ep_next');f.tracker.dramaStatusChange(chapters[1],'ep_unlock');assert.equal(f.events.at(-1).payload.drama_behavior_key,'ep_unlock');
|
||||
assert.ok(!f.events.some(e=>['dramaLike','dramaCollect','dramaPurchase','dramaComment'].includes(e.method)));
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('request headers preserve SDK values and snapshot the selected purchase chapter independently of route changes',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();f.tracker.setDrama(drama,chapters[0],chapters);
|
||||
const pending=f.tracker.requestHeaders('playlet/chapter/unlock','POST',{playletId:23456,chapterId:90002});
|
||||
f.location.pathname='/shorts/';f.tracker.pageView();
|
||||
const headers=await pending,context=JSON.parse(Buffer.from(headers['X-DC-Request-Context'],'base64url'));
|
||||
assert.deepEqual(context.common,f.fields);assert.equal(context.drama.drama_id,'23456');assert.equal(context.drama.video_id,'90002');assert.equal(context.drama.episode,2);
|
||||
assert.equal(context.event_name,'drama_purchase');assert.match(context.request_id,/^[a-f0-9]{32}$/);assert.equal(context.request_id,headers['X-Tracker-Request-Id']);
|
||||
assert.deepEqual(JSON.parse(Buffer.from(headers['X-SDK-Common'],'base64')),f.fields);
|
||||
const videoComment=await f.tracker.requestHeaders('comment/send','POST',{objectId:2,objectType:20});assert.equal(videoComment['X-DC-Request-Context'],undefined);
|
||||
const order=await f.tracker.requestHeaders('coin/buy','POST',{});assert.ok(order['X-SDK-Common']);assert.equal(order['X-Tracker-Event-Name'],undefined);
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('terminal playback errors report failed once per attempt, exclude preview and use the video dictionary',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
f.tracker.setDrama(drama,chapters[0],chapters);
|
||||
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
|
||||
f.tracker.videoEvent({type:'playing',target:video});f.setClock(5000);video.currentTime=30;
|
||||
f.tracker.playbackFailed(video);f.tracker.playbackFailed(video);
|
||||
const failed=f.events.filter(e=>e.payload.play_behavior_key==='ep_failed');assert.equal(failed.length,1);assert.equal(failed[0].payload.play_behavior_name,'播放失败');assert.equal(failed[0].payload.play_duration,4);
|
||||
mode.dataset.playMode='preview';f.tracker.prepareDramaModeChange();f.tracker.playbackFailed(video);assert.equal(f.events.filter(e=>e.payload.play_behavior_key==='ep_failed').length,1);
|
||||
mode.dataset.playMode='main';f.tracker.prepareDramaModeChange();f.tracker.playbackFailed(video);assert.equal(f.events.filter(e=>e.payload.play_behavior_key==='ep_failed').length,2);
|
||||
f.tracker.setDrama(drama,null,[],true);assert.equal(f.events.at(-1).payload.video_behavior_name,'视频展示');
|
||||
f.tracker.prepareDramaModeChange();f.tracker.playbackFailed(video);assert.equal(f.events.at(-1).method,'videoEvent');assert.equal(f.events.at(-1).payload.video_behavior_key,'video_failed');assert.equal(f.events.at(-1).payload.video_behavior_name,'播放失败');
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('completion includes 100 percent play progress while preserving the final playback interval',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();f.tracker.setDrama(drama,chapters[0],chapters);
|
||||
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
|
||||
f.tracker.videoEvent({type:'playing',target:video});f.setClock(9000);video.currentTime=100;video.ended=true;f.tracker.videoEvent({type:'ended',target:video});
|
||||
const [complete,progress]=f.events.slice(-2);assert.equal(complete.payload.play_behavior_key,'ep_complete');assert.equal(progress.payload.play_behavior_key,'ep_play');
|
||||
assert.equal(complete.payload.play_progress,100);assert.equal(progress.payload.play_progress,100);assert.equal(complete.payload.play_duration,8);assert.equal(progress.payload.play_duration,8);
|
||||
const count=f.events.length;f.tracker.reportProgress();assert.equal(f.events.length,count,'the final playback interval must be consumed after progress reporting');
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('all SDK public fields reach login and order requests unchanged including numeric timestamps and optional system metadata',async()=>{
|
||||
const f=fixture();try{
|
||||
Object.assign(f.fields,{device_brand:'品牌',device_model:'model',system_name:'test-system',system_version:'1.2.3'});
|
||||
for(const path of ['auth/login','auth/register','coin/buy','vip/pay']){
|
||||
const headers=await f.tracker.requestHeaders(path,'POST',{}),forwarded=trackingHeaders(new Headers(headers),path,'POST');
|
||||
assert.deepEqual(JSON.parse(Buffer.from(forwarded['X-SDK-Common'],'base64')),f.fields);assert.equal(forwarded['X-DC-Request-Context'],undefined);
|
||||
assert.match(headers['X-User-Agent'],/^DevID=business-device;DevType=PC;SysType=h5_pc;Ver=1.0.0;BuildID=web;/);
|
||||
}
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('the BFF accepts only the correct route, app, common fields and context schema without blocking business data',()=>{
|
||||
const fields=common(),requestId='1234567890abcdef1234567890abcdef';
|
||||
const context={v:1,request_id:requestId,event_name:'drama_purchase',common:fields,drama:{media_id:'',drama_id:'23456',episode:2,video_id:'90002',recommend_trace_id:''}};
|
||||
const source=new Headers({'X-SDK-Common':base64JSON(Object.fromEntries(SDK_COMMON_KEYS.map(k=>[k,fields[k]]))),'X-Tracker-Request-Id':requestId,'X-Tracker-Event-Name':'drama_purchase','X-DC-Request-Context':base64JSON(context,true)});
|
||||
const forwarded=trackingHeaders(source,'playlet/chapter/unlock','POST');assert.deepEqual(JSON.parse(Buffer.from(forwarded['X-DC-Request-Context'],'base64url')),context);
|
||||
assert.equal(JSON.parse(Buffer.from(forwarded['X-SDK-Common'],'base64')).sid,'sid-raw');assert.equal(forwarded.Authorization,undefined);
|
||||
for(const [path,method] of [['playlet/collect/set','POST'],['config/info','GET'],['playlet/chapter/unlock','GET']])assert.equal(trackingHeaders(source,path,method)['X-DC-Request-Context'],undefined);
|
||||
for(const invalid of [{...context,request_id:'other'},{...context,common:{...fields,app_id:'JHA-1050'}},{...context,common:{...fields,token:'secret'}},{...context,drama:{...context.drama,episode:0}}]){
|
||||
const headers=new Headers(source);headers.set('X-DC-Request-Context',base64JSON(invalid,true));const result=trackingHeaders(headers,'playlet/chapter/unlock','POST');assert.equal(result['X-DC-Request-Context'],undefined);assert.ok(result['X-SDK-Common']);
|
||||
}
|
||||
assert.deepEqual(trackingHeaders(new Headers({'X-SDK-Common':'invalid','X-DC-Request-Context':'x'.repeat(9000)}),'playlet/chapter/unlock','POST'),{});
|
||||
});
|
||||
@@ -46,9 +46,12 @@ const config = await compile("config");
|
||||
const protocol = await compile("protocol");
|
||||
const cache = await compile("public-cache");
|
||||
sharedDependencies["./user-agent"] = await compile("user-agent");
|
||||
sharedDependencies["../tracking"] = await compile("../tracking");
|
||||
sharedDependencies["./tracking-headers"] = await compile("tracking-headers");
|
||||
sharedDependencies["./sdk-headers"] = await compile("sdk-headers");
|
||||
sharedDependencies["./client-headers"] = await compile("client-headers");
|
||||
sharedDependencies["../site-navigation"] = await compile("../site-navigation", {
|
||||
"./api/protocol": protocol,
|
||||
"./routing": await compile("../routing"),
|
||||
});
|
||||
|
||||
test("development uses its configured domain and supports legacy overrides", () => {
|
||||
delete environment.UPSTREAM_API_URL;
|
||||
|
||||
@@ -24,11 +24,11 @@ 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/hot/list','playlet/home/config/detail/list','statcenter/sync'])assert.equal(server.allowedRequest(path,method),false,method+' '+path);
|
||||
});
|
||||
|
||||
test('the documented likes list is an authenticated GET endpoint',()=>{
|
||||
assert.equal(server.allowedRequest('playlet/like/list','GET'),true);
|
||||
assert.equal(server.allowedRequest('playlet/like/list','POST'),false);
|
||||
test('member list, coin, recharge, feedback and account endpoints are no longer proxied',()=>{
|
||||
for(const path of ['playlet/like/list','playlet/collect/list','playlet/history/list','coin/list','recharge/list','feedback/add','user/upload','auth/login','auth/register'])
|
||||
for(const method of ['GET','POST'])assert.equal(server.allowedRequest(path,method),false,method+' '+path);
|
||||
assert.equal(server.PUBLIC_PATHS.has('playlet/like/list'),false);
|
||||
assert.deepEqual(contract['GET playlet/like/list'].query,['pageNum','pageSize']);
|
||||
assert.equal(server.allowedRequest('auth/guest','POST'),true);
|
||||
});
|
||||
|
||||
test('all public list routes send only supported query fields with their native page numbers',()=>{
|
||||
|
||||
+127
-720
File diff suppressed because it is too large
Load Diff
@@ -1,91 +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 protocol=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const render=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocol)));
|
||||
const apps=await moduleUrl('../src/lib/client/apps.ts',code=>code.replace("'../api/protocol'",JSON.stringify(protocol)).replace("'./render'",JSON.stringify(render)));
|
||||
const {loadAppCategories,renderAppDirectory,scrollToAppCategory}=await import(apps);
|
||||
const app=id=>({id,name:'应用 '+id,url:'https://example.com/app/'+id,logo:'https://example.com/icon.png'});
|
||||
const category=(id,appList)=>({id,title:'分类 '+id,...(appList===undefined?{}:{appList})});
|
||||
|
||||
test('benefit applications report linked advertisements with their API ids and ordinary applications as content',()=>{
|
||||
const html=renderAppDirectory([category(3,[app(11),{...app(12),advitiseId:'ad-12',location:8}])]);
|
||||
assert.match(html,/data-track-item data-item-id="11" data-item-name="应用 11" data-item-type="app" data-parent-item-id="3"/);
|
||||
assert.match(html,/data-track-ad="external" data-ad-id="ad-12" data-ad-clickable="1" data-ad-slot-key="8" data-ad-slot-name="福利应用" data-ad-type="icon"/);
|
||||
});
|
||||
|
||||
test('loads every missing category once, preserves directory order, and omits empty categories',async()=>{
|
||||
const calls=[],controller=new AbortController();
|
||||
const api={async get(path,query,signal){
|
||||
assert.equal(path,'applist/list');assert.equal(signal,controller.signal);calls.push(query.categoryId);
|
||||
if(query.categoryId===0)return {categoryList:[category(2,[]),category(1),category(3,[]),category(4),category(2)],category:category(1,[app(11)])};
|
||||
return {category:category(query.categoryId,query.categoryId===2?[app(22)]:[])};
|
||||
}};
|
||||
const result=await loadAppCategories(api,controller.signal);
|
||||
assert.deepEqual(calls,[0,2,3,4]);assert.deepEqual(result.map(item=>item.id),[2,1]);
|
||||
const html=renderAppDirectory(result);
|
||||
for(const id of [2,1]){assert.ok(html.includes(`aria-controls="apps-${id}"`));assert.ok(html.includes(`id="apps-${id}"`));}
|
||||
for(const id of [3,4]){assert.ok(!html.includes(`data-app-category="${id}"`));assert.ok(!html.includes(`id="apps-${id}"`));}
|
||||
assert.equal((html.match(/class="benefit-section"/g)||[]).length,2);
|
||||
});
|
||||
|
||||
test('uses embedded applications without additional requests and escapes API text and links',async()=>{
|
||||
let calls=0;
|
||||
const result=await loadAppCategories({async get(){calls++;return {categoryList:[{...category(7,[{...app(1),name:'应用 <script>',url:'https://example.com/?a=1&b=2'}]),title:'分类 <img>'},category(8,[app(2)])]};}},new AbortController().signal);
|
||||
assert.equal(calls,1);
|
||||
const html=renderAppDirectory(result);
|
||||
assert.ok(html.includes('分类 <img>'));assert.ok(html.includes('应用 <script>'));assert.ok(html.includes('?a=1&b=2'));
|
||||
assert.ok(html.includes('data-src="https://example.com/icon.png"'));assert.ok(!html.includes('<script>'));
|
||||
});
|
||||
|
||||
test('empty and malformed application lists create no category options or empty sections',async()=>{
|
||||
for(const data of [null,{}, {categoryList:null},{categoryList:[null,{},category(1,[]),category(2,[null,{},false]),category(3,'invalid')]}]){
|
||||
const result=await loadAppCategories({async get(){return data;}},new AbortController().signal);
|
||||
assert.deepEqual(result,[]);
|
||||
const html=renderAppDirectory(result);assert.ok(html.includes('暂无福利应用'));
|
||||
assert.ok(!html.includes('data-app-category'));assert.ok(!html.includes('benefit-section'));
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps available groups after a category failure and reports failure when none can load',async()=>{
|
||||
const unavailable=new Error('category unavailable');
|
||||
for(const available of [true,false]){
|
||||
const api={async get(_,query){
|
||||
if(query.categoryId===0)return {categoryList:[category(1),category(2)],category:available?category(1,[app(1)]):undefined};
|
||||
throw unavailable;
|
||||
}};
|
||||
if(available)assert.deepEqual((await loadAppCategories(api,new AbortController().signal)).map(item=>item.id),[1]);
|
||||
else await assert.rejects(loadAppCategories(api,new AbortController().signal),unavailable);
|
||||
}
|
||||
});
|
||||
|
||||
test('aborted navigation discards late category results',async()=>{
|
||||
const controller=new AbortController();let finish;
|
||||
const result=loadAppCategories({async get(_,query){
|
||||
if(query.categoryId===0)return {categoryList:[category(1)]};
|
||||
return new Promise(resolve=>{finish=()=>resolve({category:category(1,[app(1)])});});
|
||||
}},controller.signal);
|
||||
await new Promise(resolve=>setImmediate(resolve));controller.abort();finish();
|
||||
assert.deepEqual(await result,[]);
|
||||
});
|
||||
|
||||
test('category selection scrolls to existing sections and updates selection without replacing content',()=>{
|
||||
const previousWindow=globalThis.window,scrolls=[];
|
||||
const buttons=[1,2].map(id=>({dataset:{appCategory:String(id)},setAttribute(key,value){this[key]=value;}}));
|
||||
const root={querySelector:selector=>selector==='#apps-2'?{scrollIntoView:options=>scrolls.push(options)}:null,querySelectorAll:()=>buttons};
|
||||
try{
|
||||
globalThis.window={matchMedia:()=>({matches:false})};
|
||||
scrollToAppCategory(root,'2');assert.deepEqual(scrolls,[{behavior:'smooth',block:'start'}]);
|
||||
assert.equal(buttons[0]['aria-pressed'],'false');assert.equal(buttons[1]['aria-pressed'],'true');
|
||||
globalThis.window.matchMedia=()=>({matches:true});scrollToAppCategory(root,'2');assert.equal(scrolls[1].behavior,'auto');
|
||||
scrollToAppCategory(root,'3');scrollToAppCategory(root,'invalid');assert.equal(scrolls.length,2);
|
||||
}finally{globalThis.window=previousWindow;}
|
||||
});
|
||||
+2
-163
@@ -1,3 +1,4 @@
|
||||
import {linkShared} from './helpers/shared-modules.mjs';
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -7,7 +8,7 @@ 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');
|
||||
return 'data:text/javascript;base64,'+Buffer.from(linkShared(replace(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
|
||||
}
|
||||
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const authUrl=await moduleUrl('../src/lib/client/auth.ts');
|
||||
@@ -23,22 +24,6 @@ const response=(data,code=200,headers={})=>new Response(JSON.stringify({code,dat
|
||||
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']]);
|
||||
@@ -100,36 +85,6 @@ 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/app/auth/guest','/api/app/auth/register']);
|
||||
assert.equal(binding.user.userId,8);
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid credentials preserve the current identity; late expired requests cannot clear a new login',async()=>{
|
||||
const api=await client();api.setUser(guest);
|
||||
const gate=deferred();let invalid=true;
|
||||
await withFetch(async url=>{
|
||||
if(url.endsWith('/auth/login'))return invalid?response(null,1000):response(member);
|
||||
await gate.promise;return response(null,1000);
|
||||
},async()=>{
|
||||
await assert.rejects(api.post('auth/login',{account:'player',password:'bad'}));assert.equal(api.user.userId,7);
|
||||
const pending=api.get('user/center/data');await Promise.resolve();invalid=false;
|
||||
await api.post('auth/login',{account:'player',password:'Secure123'});gate.resolve();await assert.rejects(pending);
|
||||
assert.equal(api.user.userId,8);
|
||||
});
|
||||
});
|
||||
|
||||
test('registered expiry prompts once and blocks automatic guest downgrade',async()=>{
|
||||
const api=await client();api.setUser(member);let prompts=0,calls=0;api.addEventListener('expired',()=>prompts++);
|
||||
await withFetch(async()=>{calls++;return response(null,1000,{'X-Auth-State':'login-required'});},async()=>{
|
||||
@@ -176,46 +131,6 @@ test('restoring an expired registered cookie prompts for login even before a use
|
||||
});
|
||||
});
|
||||
|
||||
test('auth dialog renders binding confirmation, blocks duplicate submission, and clears revealed passwords on close',async()=>{
|
||||
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
|
||||
const trackingUrl=await moduleUrl('../src/lib/tracking.ts');
|
||||
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../tracking'",JSON.stringify(trackingUrl)));
|
||||
const {bindAccountUI,openAuth}=await import(accountUrl);
|
||||
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
|
||||
const listeners={},error={textContent:''},button={textContent:'绑定账号',disabled:false};
|
||||
const password={value:'Secure123',type:'password'},confirmation={value:'Secure123',type:'password'};
|
||||
const form={dataset:{mode:'register'},isConnected:true,reportValidity:()=>true,
|
||||
querySelector:selector=>selector==='#auth-error'?error:button,querySelectorAll:()=>[password,confirmation]};
|
||||
const dialog={id:'member-auth-dialog',innerHTML:'',open:false,classList:{toggle(){}},querySelector:selector=>selector==='#auth-account'?{focus(){}}:selector==='#auth-password'?password:null,
|
||||
querySelectorAll:()=>[password,confirmation],addEventListener:(name,handler)=>listeners[name]=handler,showModal(){this.open=true;},close(){this.open=false;listeners.close();}};
|
||||
const gate=deferred();let submitted=0,routed=0,refreshed=0;const bindState={textContent:'未绑定'};
|
||||
const app={api:{post:async(path,payload)=>{submitted++;assert.equal(path,'auth/register');assert.equal(payload.accountNo,'player01');await gate.promise;},refreshUser:async()=>{refreshed++;}},notify(){},async route(){routed++;}};
|
||||
globalThis.document={getElementById:id=>id==='member-auth-dialog'?dialog:null,querySelector:selector=>selector==='[data-bind-state]'?bindState:null};
|
||||
globalThis.FormData=class extends OldFormData{constructor(){super();for(const [key,value] of Object.entries({account:'player01',password:'Secure123',passwordConfirm:'Secure123',agree:'on'}))this.append(key,value);}};
|
||||
try{
|
||||
bindAccountUI(app);openAuth('bind');assert.ok(dialog.open);
|
||||
assert.ok(dialog.innerHTML.includes('name="passwordConfirm"'));assert.ok(!dialog.innerHTML.includes('name="email"'));
|
||||
const toggle={dataset:{authPasswordToggle:'auth-password'},setAttribute(){}};
|
||||
listeners.click({target:{closest:()=>toggle}});assert.equal(password.type,'text');
|
||||
const event={target:form,preventDefault(){}};listeners.submit(event);listeners.submit(event);
|
||||
assert.equal(submitted,1);assert.equal(button.disabled,true);
|
||||
gate.resolve();await new Promise(resolve=>setImmediate(resolve));
|
||||
// Bind stays on the settings page — no full reload that would replay a stale drama_session.
|
||||
assert.equal(routed,0);assert.equal(refreshed,1);assert.equal(button.disabled,false);assert.equal(password.value,'');assert.equal(confirmation.value,'');assert.equal(bindState.textContent,'已绑定');
|
||||
openAuth('switch');assert.ok(dialog.innerHTML.includes('切换账号'));
|
||||
assert.ok(dialog.innerHTML.includes('data-mode="login"'));assert.ok(dialog.innerHTML.includes('data-auth-switch="1"'));
|
||||
form.dataset.mode='login';form.dataset.authSwitch='1';
|
||||
const current={userId:7};app.api.user=current;
|
||||
app.api.post=async(path,payload)=>{assert.equal(path,'auth/login');assert.equal(payload.switchAccount,true);assert.equal(payload.account,'player01');return {userId:9};};
|
||||
listeners.submit(event);await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(routed,1);assert.equal(refreshed,2);
|
||||
app.api.post=async()=>current;
|
||||
listeners.submit(event);await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(routed,1);assert.equal(error.textContent,'切换失败,请确认账号和密码');
|
||||
}finally{globalThis.document=oldDocument;globalThis.FormData=OldFormData;}
|
||||
});
|
||||
|
||||
|
||||
test('a server-prepared session is reused without another guest handshake and a sign-in snapshot blocks automatic downgrade',async()=>{
|
||||
await withFetch(()=>assert.fail('prepared sessions must not refetch auth'),async()=>{
|
||||
const ready=await client();ready.seedSession(member);assert.equal((await ready.initializeSession()).userId,member.userId);
|
||||
@@ -421,61 +336,6 @@ test('registered expiry still never recovers as a guest',async()=>{
|
||||
});
|
||||
});
|
||||
|
||||
test('binding replaces the guest bearer with the new register token and getInfo uses it',async()=>{
|
||||
const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
|
||||
const storage=new Map([['uuid','bind-device']]);
|
||||
let cookieJar='drama_device=bind-device';
|
||||
Object.defineProperty(globalThis,'document',{configurable:true,value:{
|
||||
get cookie(){return cookieJar;},
|
||||
set cookie(value){
|
||||
const [pair]=String(value).split(';');
|
||||
const sep=pair.indexOf('=');
|
||||
const name=pair.slice(0,sep);
|
||||
const body=pair.slice(sep+1);
|
||||
const parts=cookieJar?cookieJar.split('; ').filter(item=>!item.startsWith(name+'=')):[];
|
||||
if(body&&!String(value).includes('Max-Age=0'))parts.push(`${name}=${body}`);
|
||||
cookieJar=parts.join('; ');
|
||||
},
|
||||
}});
|
||||
Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key)??null,setItem:(key,value)=>storage.set(key,String(value)),removeItem:key=>storage.delete(key)}});
|
||||
Object.defineProperty(globalThis,'location',{configurable:true,value:{protocol:'https:',search:''}});
|
||||
try{
|
||||
const api=await client();const calls=[];
|
||||
await withFetch(async(url,options)=>{
|
||||
const path=String(url);
|
||||
const authorization=options.headers?.Authorization||options.headers?.authorization;
|
||||
calls.push({path,authorization});
|
||||
if(path.endsWith('/auth/guest'))return response({...guest,token:'guest-before-bind'});
|
||||
if(path.endsWith('/auth/register')){
|
||||
assert.equal(authorization,'guest-before-bind');
|
||||
// Upstream invalidates the guest JWT and returns a new registered token.
|
||||
return response({...member,token:'bound-after-register',accountNo:'player01'});
|
||||
}
|
||||
if(path.endsWith('/auth/getInfo')){
|
||||
// Bootstrap uses the guest token; post-bind refresh must use the new register token.
|
||||
if(authorization==='guest-before-bind')return response({...guest,token:''});
|
||||
assert.equal(authorization,'bound-after-register');
|
||||
return response({...member,accountNo:'player01',token:''});
|
||||
}
|
||||
return response({});
|
||||
},async()=>{
|
||||
await api.bootstrapGuestSession();
|
||||
await api.post('auth/register',{accountNo:'player01',password:'Secure123'});
|
||||
await api.refreshUser();
|
||||
assert.equal(api.user.accountNo,'player01');
|
||||
assert.equal(api.user.type,1);
|
||||
assert.equal(storage.get('drama_bearer'),'bound-after-register');
|
||||
assert.ok(calls.some(call=>call.path.endsWith('/auth/register')&&call.authorization==='guest-before-bind'));
|
||||
assert.ok(calls.some(call=>call.path.endsWith('/auth/getInfo')&&call.authorization==='bound-after-register'));
|
||||
});
|
||||
}finally{
|
||||
for(const [key,descriptor] of Object.entries(originals)){
|
||||
if(descriptor)Object.defineProperty(globalThis,key,descriptor);
|
||||
else delete globalThis[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('persisted bearer restores getInfo after reload without opening the login dialog',async()=>{
|
||||
const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
|
||||
const storage=new Map([['drama_bearer','bound-jwt'],['uuid','bind-device']]);
|
||||
@@ -505,24 +365,3 @@ test('persisted bearer restores getInfo after reload without opening the login d
|
||||
}
|
||||
});
|
||||
|
||||
test('soft bootstrap getInfo failure after bind never opens the login dialog',async()=>{
|
||||
const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
|
||||
const storage=new Map([['drama_bearer','stale-bound-jwt']]);
|
||||
Object.defineProperty(globalThis,'document',{configurable:true,value:{cookie:''}});
|
||||
Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key)??null,setItem:(key,value)=>storage.set(key,String(value)),removeItem:key=>storage.delete(key)}});
|
||||
Object.defineProperty(globalThis,'location',{configurable:true,value:{protocol:'https:',search:''}});
|
||||
try{
|
||||
const api=await client();api.seedSession(member);let prompts=0;api.addEventListener('expired',()=>prompts++);
|
||||
await withFetch(async()=>response(null,1000),async()=>{
|
||||
assert.equal((await api.bootstrapGuestSession())?.userId,member.userId);
|
||||
assert.equal(prompts,0);
|
||||
assert.equal(api.user.userId,member.userId);
|
||||
assert.equal(storage.has('drama_bearer'),false);
|
||||
});
|
||||
}finally{
|
||||
for(const [key,descriptor] of Object.entries(originals)){
|
||||
if(descriptor)Object.defineProperty(globalThis,key,descriptor);
|
||||
else delete globalThis[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {linkShared} from './helpers/shared-modules.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
@@ -6,12 +7,12 @@ import ts from 'typescript';
|
||||
async function moduleUrl(path,imports={}){
|
||||
let code=ts.transpileModule(await readFile(new URL('../src/lib/'+path+'.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
for(const [name,url] of Object.entries(imports))code=code.replace("'"+name+"'",JSON.stringify(url));
|
||||
return 'data:text/javascript;base64,'+Buffer.from(code).toString('base64');
|
||||
return 'data:text/javascript;base64,'+Buffer.from(linkShared(code)).toString('base64');
|
||||
}
|
||||
const uaUrl=await moduleUrl('api/user-agent');
|
||||
const {buildUserAgent,buildSdkUserAgent,userAgentSid}=await import(uaUrl);
|
||||
const trackingUrl=await moduleUrl('api/tracking-headers',{'../tracking':await moduleUrl('tracking')});
|
||||
const {clientHeaders}=await import(await moduleUrl('api/client-headers',{'./user-agent':uaUrl,'./tracking-headers':trackingUrl}));
|
||||
const sdkUrl=await moduleUrl('api/sdk-headers');
|
||||
const {clientHeaders}=await import(await moduleUrl('api/client-headers',{'./user-agent':uaUrl,'./sdk-headers':sdkUrl}));
|
||||
|
||||
// Generated by running the reference Go BuildXUserAgent function without changing its logic.
|
||||
const samples=JSON.parse(await readFile(new URL('./fixtures/request-user-agents.json',import.meta.url),'utf8'));
|
||||
@@ -24,7 +25,7 @@ test('existing X-User-Agent wins over SDK fields and network headers remain sepa
|
||||
const fields={sid:'sdk-sid',device_id:'sdk-device',device:'Android',device_fingerprint:'raw',fp_version:'3',app_version:'1.0.0'};
|
||||
const headers=clientHeaders(new Headers({'User-Agent':' Browser UA ','X-User-Agent':xua,'X-Real-IP':'203.0.113.9','X-Forwarded-For':'203.0.113.8, 10.0.0.1','X-SDK-Common':Buffer.from(JSON.stringify(fields)).toString('base64'),Cookie:'drama_session=private',Authorization:'private'}));
|
||||
assert.equal(headers['User-Agent'],'Browser UA');assert.equal(headers['X-User-Agent'],xua);assert.equal(headers['X-Real-IP'],'203.0.113.9');
|
||||
assert.deepEqual(JSON.parse(Buffer.from(headers['X-SDK-Common'],'base64')),fields);
|
||||
assert.deepEqual(JSON.parse(Buffer.from(headers['X-SDK-Common'],'base64')),{sid:'sdk-sid',device_id:'sdk-device',app_version:'1.0.0'});
|
||||
for(const key of ['Cookie','Authorization','X-Forwarded-For'])assert.equal(headers[key],undefined);
|
||||
});
|
||||
|
||||
|
||||
+21
-14
@@ -1,3 +1,4 @@
|
||||
import {linkShared} from './helpers/shared-modules.mjs';
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -7,37 +8,43 @@ 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');
|
||||
return 'data:text/javascript;base64,'+Buffer.from(linkShared(replace(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
|
||||
}
|
||||
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
|
||||
const detailUrl=await moduleUrl('../src/lib/client/detail.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)));
|
||||
const {overviewMarkup,officialLinks,bindSeriesDescription,detailActionIcon}=await import(detailUrl);
|
||||
|
||||
test('overview retains API data, encrypted image loading and real chapter links in the original layout',()=>{
|
||||
const html=overviewMarkup({id:23456,title:'剧名 <script>',coverImg:'https://cdn.example/encrypted.webp',description:'真实简介 <img onerror=alert(1)>',themeNames:['古装','爱情'],isNew:true,countCollect:23000,countLike:100,heatValue:999},{id:90042,currentEpisode:1},'<div class="play-detail-actions">actions</div>');
|
||||
test('cinematic overview keeps API data, encrypted image loading and real chapter links',()=>{
|
||||
const html=overviewMarkup({id:23456,title:'剧名 <script>',coverImg:'https://cdn.example/encrypted.webp',description:'真实简介 <img onerror=alert(1)>',themeNames:['古装','爱情'],totalEpisode:12,updateStatus:1,isNew:true,countCollect:23000,countLike:100,heatValue:999},{id:90042,currentEpisode:1});
|
||||
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('#古装'));assert.ok(html.includes('#爱情'));
|
||||
assert.ok(html.includes('class="cine-hero"'));assert.ok(html.includes('class="cine-hero-image"'));assert.ok(!html.includes('series-poster'));
|
||||
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('<span class="cine-kicker">短剧 · 连载中</span>'));assert.ok(html.includes('<h1 id="play-detail-title">'));
|
||||
assert.ok(html.includes('href="/play/23456/90042/"'));assert.ok(html.includes('<span>立即观看</span>'));
|
||||
for(const text of ['999热度','2.3万收藏','100点赞','data-engage="like"','data-engage="collect"','data-share'])assert.ok(html.includes(text),text);
|
||||
assert.ok(html.includes('aria-controls="series-description-text"'));assert.ok(html.includes('data-app-download-entry'));
|
||||
assert.ok(!overviewMarkup({id:7,title:'无章节'},undefined,'').includes('href="/play/'));
|
||||
const rich=overviewMarkup({id:8,title:'完结剧',totalEpisode:10,updateStatus:2,countBrowse:167,anchor:'演员 <b>',showOriginal:true,operateType:2,tagNames:['家庭'],tagSlugs:['jiating'],themeNames:['家庭']},{id:1},'');
|
||||
for(const text of ['全10集','已完结','167次观看','演员 演员 <b>','series-badge--original','series-badge--comic','href="/tag/jiating/"'])assert.ok(rich.includes(text),text);
|
||||
assert.equal(rich.split('家庭').length-1,1);
|
||||
assert.ok(!overviewMarkup({id:7,title:'无章节'},undefined).includes('href="/play/'));
|
||||
const later=overviewMarkup({id:9,title:'从中间开始',totalEpisode:30},{id:5,currentEpisode:3});
|
||||
assert.ok(later.includes('从第 3 集开始'));
|
||||
const single=overviewMarkup({id:8,title:'单集',totalEpisode:1,tagNames:['家庭'],tagSlugs:['jiating'],themeNames:['家庭']},{id:1});
|
||||
assert.ok(single.includes('<span class="cine-kicker">视频</span>'));assert.ok(!single.includes('全1集'));
|
||||
assert.ok(single.includes('href="/tag/jiating/"'));assert.equal(single.split('#家庭').length-1,1);
|
||||
});
|
||||
|
||||
test('official links use configured safe URLs and retain the original feedback entrance',()=>{
|
||||
test('official links use configured safe URLs without a customer-service 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}"`));
|
||||
for(const url of ['https://entry.example/','https://permanent.example/','https://t.me/example'])assert.ok(html.includes(`href="${url}"`));
|
||||
assert.ok(html.includes('点击加入黄果短剧官方 Telegram 群'));
|
||||
for(const text of ['官方客服','https://contact.example','/info/feedback/'])assert.ok(!html.includes(text),text);
|
||||
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('href="https://backup.example/"'));assert.ok(!fallback.includes('javascript:'));
|
||||
assert.ok(!fallback.includes('huangguo.me'));assert.ok(!fallback.includes('huangguo.tv'));
|
||||
});
|
||||
|
||||
test('original action SVGs remain accessible and inherit the active colors',()=>{
|
||||
for(const kind of ['like','save','share','feedback']){
|
||||
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="'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -285,7 +285,7 @@ test("project profiles and Next configuration keep the requested API and port wi
|
||||
command.env.PORT,
|
||||
environment === "production" ? "21042" : "21030",
|
||||
);
|
||||
assert.equal(command.env.NEXT_PUBLIC_TRACKER_APP_ID, "1050");
|
||||
assert.equal(command.env.NEXT_PUBLIC_TRACKER_APP_ID, undefined);
|
||||
const ignored = spawnSync(
|
||||
"git",
|
||||
["check-ignore", "--no-index", `config/${environment}.local.env`],
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {linkShared} from './shared-modules.mjs';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
const compile=async path=>ts.transpileModule(await readFile(new URL('../../'+path,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText;
|
||||
const moduleUrl=source=>'data:text/javascript;base64,'+Buffer.from(source).toString('base64');
|
||||
const moduleUrl=source=>'data:text/javascript;base64,'+Buffer.from(linkShared(source)).toString('base64');
|
||||
const routingUrl=moduleUrl(await compile('src/lib/routing.ts'));
|
||||
const protocolUrl=moduleUrl(await compile('src/lib/api/protocol.ts'));
|
||||
const playbackUrl=moduleUrl((await compile('src/lib/client/playback.ts')).replace("'../api/protocol'",JSON.stringify(protocolUrl)));
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
// Dependency-free site modules (brand, contact, footer filtering, download address, visitor headers)
|
||||
// are linked into transpiled test modules by specifier, whatever relative depth they are imported from.
|
||||
const compile=async path=>ts.transpileModule(await readFile(new URL('../../src/lib/'+path,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText;
|
||||
const url=source=>'data:text/javascript;base64,'+Buffer.from(source).toString('base64');
|
||||
const protocol=url(await compile('api/protocol.ts'));
|
||||
const userAgent=url(await compile('api/user-agent.ts'));
|
||||
const modules={
|
||||
brand:url(await compile('brand.ts')),
|
||||
contact:url(await compile('contact.ts')),
|
||||
'footer-links':url(await compile('footer-links.ts')),
|
||||
'app-download':url((await compile('app-download.ts')).replace(/(['"])\.\/api\/protocol\1/g,JSON.stringify(protocol))),
|
||||
'visitor-headers':url((await compile('client/visitor-headers.ts')).replace(/(['"])\.\.\/api\/user-agent\1/g,JSON.stringify(userAgent))),
|
||||
};
|
||||
|
||||
export function linkShared(code){
|
||||
return code.replace(/(['"])(?:\.\.?\/)+(brand|contact|footer-links|app-download|visitor-headers)\1/g,(_,quote,name)=>JSON.stringify(modules[name]));
|
||||
}
|
||||
|
||||
/** Namespace of a shared module, for CommonJS test harnesses that resolve `require` by hand. */
|
||||
export const sharedModule=name=>import(modules[name]);
|
||||
@@ -1,3 +1,4 @@
|
||||
import {linkShared} from './helpers/shared-modules.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
@@ -5,7 +6,7 @@ import ts from 'typescript';
|
||||
|
||||
async function moduleUrl(file,replace=code=>code){
|
||||
const source=await readFile(new URL(file,import.meta.url),'utf8');
|
||||
return 'data:text/javascript;base64,'+Buffer.from(replace(ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText)).toString('base64');
|
||||
return 'data:text/javascript;base64,'+Buffer.from(linkShared(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))));
|
||||
@@ -20,13 +21,15 @@ test('legacy navigation fields and address sections supply valid configured dest
|
||||
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],'');
|
||||
assert.deepEqual(empty.routes,[]);assert.equal(empty.permanent,'');for(const key of ['telegram','tgGroup','twitter'])assert.equal(key in empty,false);
|
||||
assert.equal(empty.siteName,'黄果短剧');
|
||||
});
|
||||
|
||||
test('navigation identity and contacts use configuration, support TG handles, and omit demo accounts',()=>{
|
||||
test('navigation identity uses configuration, rebrands legacy names and never exposes configured contacts',()=>{
|
||||
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');
|
||||
for(const key of ['telegram','tgGroup','twitter'])assert.equal(key in data,false);
|
||||
assert.equal(navigationPageData({config:{dhSiteName:'禁果短剧'}}).siteName,'黄果短剧');
|
||||
assert.equal(navigationPageData({config:{dhSiteName:'配置名称'}}).siteName,'配置名称');
|
||||
assert.equal(navigationPageData({seoInfo:{webSiteName:'站点名称'}}).siteName,'站点名称');
|
||||
});
|
||||
|
||||
+5
-189
@@ -1,3 +1,4 @@
|
||||
import {linkShared} from './helpers/shared-modules.mjs';
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -10,17 +11,11 @@ if(typeof globalThis.File==='undefined')globalThis.File=NodeFile;
|
||||
async function moduleUrl(file,replace=code=>code){
|
||||
const filename=new URL(file,import.meta.url);
|
||||
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||||
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
|
||||
return 'data:text/javascript;base64,'+Buffer.from(linkShared(replace(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
|
||||
}
|
||||
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
|
||||
const authUrl=await moduleUrl('../src/lib/client/auth.ts');
|
||||
const trackingUrl=await moduleUrl('../src/lib/tracking.ts');
|
||||
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../tracking'",JSON.stringify(trackingUrl)));
|
||||
const feedbackUrl=await moduleUrl('../src/lib/client/feedback.ts',code=>code.replace('"./render"',JSON.stringify(renderUrl)));
|
||||
const {card}=await import(renderUrl);
|
||||
const {accountMenuMarkup,settingsMarkup,memberMarkup,renderMember}=await import(accountUrl);
|
||||
const {feedbackPayload,feedbackMarkup,renderFeedbackPage}=await import(feedbackUrl);
|
||||
const flush=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
|
||||
test('ranking cards retain original poster frames and global positions, and search highlights only escaped titles',()=>{
|
||||
@@ -35,186 +30,7 @@ test('ranking cards retain original poster frames and global positions, and sear
|
||||
assert.ok(search.includes('aria-label="查看测试 <短剧>"'));
|
||||
});
|
||||
|
||||
test('personal pages use actual library data and route links inside the original summary and tabs',async()=>{
|
||||
const oldDocument=globalThis.document,root={innerHTML:''},calls=[];
|
||||
const user={userId:88,nickName:'真实昵称 <script>',recharge:2400,avatar:'https://cdn.example/avatar.webp'};
|
||||
const app={signal:new AbortController().signal,home:{},api:{ensureSession:async()=>user,get:async(path,query)=>{calls.push({path,query});return {list:[{id:7,title:'已看过的短剧'}],totalCount:72};}}};
|
||||
globalThis.document={getElementById:()=>root};
|
||||
try{
|
||||
await renderMember(app,'center',1,()=>'/member/center/');
|
||||
assert.equal(calls.at(-1).path,'playlet/history/list');
|
||||
for(const className of ['member-name-row','member-settings-link','member-wallet','coin-soft-star','member-library-tabs','member-library-toolbar','member-library-cards','member-library-item'])assert.ok(root.innerHTML.includes(className),className);
|
||||
assert.ok(root.innerHTML.includes('真实昵称 <script>'));assert.ok(root.innerHTML.includes('2,400'));assert.ok(root.innerHTML.includes('ID:88'));
|
||||
assert.ok(root.innerHTML.includes('href="/member/settings/"'));assert.ok(root.innerHTML.includes('href="/member/history/page/2/"'));assert.ok(!root.innerHTML.includes('/member/center/page/'));
|
||||
assert.ok(root.innerHTML.includes('href="/member/favorites/"'));assert.ok(root.innerHTML.includes('href="/member/likes/"'));assert.ok(root.innerHTML.includes('href="/member/purchased/"'));
|
||||
await renderMember(app,'favorites',2,page=>`/member/favorites/page/${page}/`);
|
||||
assert.equal(calls.at(-1).path,'playlet/collect/list');assert.equal(calls.at(-1).query.pageNum,2);
|
||||
assert.ok(root.innerHTML.includes('href="/member/favorites/" aria-current="page"'));
|
||||
assert.ok(!root.innerHTML.includes('吴秀英'));assert.ok(!root.innerHTML.includes('1,280'));
|
||||
const icons=Array.from({length:18},(_,index)=>({id:`icon-${index}`,title:`图标${String(index).padStart(2,'0')}`,images:`https://cdn.example/${index}.png`,link:`https://example.com/${index}`}));
|
||||
const marked=memberMarkup(user,'center',{list:[],totalCount:0},{advitise:{nineGridList:[null,{id:'bad',title:'坏图',images:'javascript:alert(1)'},...icons]}});
|
||||
assert.equal((marked.match(/class="member-ad-tile"/g)||[]).length,16);
|
||||
assert.ok(marked.indexOf('member-ad-grid')<marked.indexOf('member-summary'));
|
||||
assert.ok(marked.includes('图标00'));assert.ok(marked.includes('图标15'));
|
||||
assert.ok(!marked.includes('图标16'));assert.ok(!marked.includes('javascript:'));
|
||||
assert.ok(marked.includes('data-ad-type="icon"'));assert.ok(marked.includes('width="76"'));
|
||||
assert.ok(!memberMarkup(user,'center',{}).includes('member-ad-grid'));
|
||||
assert.ok(!memberMarkup(user,'settings',{},{advitise:{nineGridList:icons}}).includes('member-ad-tile'));
|
||||
}finally{globalThis.document=oldDocument;}
|
||||
});
|
||||
|
||||
test('likes use the protected list endpoint, native pagination and the reference library markup',async()=>{
|
||||
const previous=globalThis.document,root={innerHTML:''},calls=[];
|
||||
const user={userId:8,nickName:'点赞用户'};
|
||||
const app={signal:new AbortController().signal,home:{},api:{ensureSession:async()=>user,get:async(path,query)=>{
|
||||
calls.push({path,query});return {list:[{id:7,slug:'liked-story',title:'点赞 <短剧>',totalEpisode:8}],totalCount:72};
|
||||
}}};
|
||||
globalThis.document={getElementById:()=>root};
|
||||
try{
|
||||
await renderMember(app,'likes',2,page=>page===1?'/member/likes/':`/member/likes/page/${page}/`);
|
||||
assert.deepEqual(calls.at(-1),{path:'playlet/like/list',query:{pageNum:2,pageSize:24}});
|
||||
assert.ok(root.innerHTML.includes('href="/member/likes/" aria-current="page"><h2 id="member-library-title"'));
|
||||
assert.ok(root.innerHTML.includes('共 72 部短剧'));assert.ok(root.innerHTML.includes('href="/member/likes/page/3/"'));
|
||||
assert.ok(root.innerHTML.includes('href="/detail/liked-story/"'));assert.ok(root.innerHTML.includes('点赞 <短剧>'));
|
||||
for(const tab of ['history','favorites','likes','purchased'])assert.ok(root.innerHTML.includes(`href="/member/${tab}/"`));
|
||||
const initial={user,list:{list:[],totalCount:0}};calls.length=0;
|
||||
await renderMember(app,'likes',1,()=>'/member/likes/',initial);
|
||||
assert.equal(calls.length,0,'SSR data must be reused without a second list request');
|
||||
assert.ok(root.innerHTML.includes('member-library-empty'));assert.ok(root.innerHTML.includes('暂无点赞的短剧'));
|
||||
assert.ok(root.innerHTML.includes('href="/shorts/">去发现好剧</a>'));
|
||||
assert.ok(!memberMarkup(user,'likes',{list:null,totalCount:0}).includes('undefined'));
|
||||
}finally{globalThis.document=previous;}
|
||||
});
|
||||
|
||||
test('the account menu exposes a native likes entry for guests and registered users',()=>{
|
||||
for(const user of [null,{userId:8,nickName:'用户 <script>',isRegister:true}]){
|
||||
const html=accountMenuMarkup(user);
|
||||
assert.ok(html.includes('href="/member/likes/"'));
|
||||
assert.ok(html.includes('我的点赞'));
|
||||
assert.ok(html.includes('class="profile-row"'));
|
||||
assert.ok(html.includes('account-collection-icon'));
|
||||
assert.ok(html.includes('data-auth-mode="login"'));
|
||||
assert.ok(html.includes('data-auth-mode="bind"'));
|
||||
assert.ok(html.includes('class="profile-switch"'));
|
||||
assert.ok(html.indexOf('/member/favorites/')<html.indexOf('/member/likes/'));
|
||||
assert.ok(html.indexOf('/member/likes/')<html.indexOf('/member/history/'));
|
||||
assert.ok(html.indexOf('/member/history/')<html.indexOf('/info/deposit/'));
|
||||
assert.ok(html.indexOf('/info/deposit/')<html.indexOf('data-auth-mode="switch"'));
|
||||
assert.ok(!html.includes('已购剧集'));
|
||||
assert.ok(!html.includes('账号设置'));
|
||||
assert.ok(!html.includes('退出登录'));
|
||||
assert.ok(!html.includes('data-logout'));
|
||||
assert.ok(!html.includes('<script>'));
|
||||
if(user)assert.ok(html.includes('用户 <script>'));
|
||||
}
|
||||
const counted=accountMenuMarkup({userId:8,nickName:'甲',recharge:1280},{collectCount:3,likeCount:5});
|
||||
assert.ok(counted.includes('id="profile-saved-count">3'));
|
||||
assert.ok(counted.includes('id="profile-likes-count">5'));
|
||||
assert.ok(!counted.includes('profile-watch-count'));
|
||||
assert.ok(!counted.includes('profile-bought-count'));
|
||||
assert.ok(counted.includes('id="profile-coin-count">1,280')||counted.includes('id="profile-coin-count">1280'));
|
||||
assert.ok(!counted.includes('data-logout'));
|
||||
});
|
||||
|
||||
test('settings preserve actual profile fields and auth actions inside the reference cards',()=>{
|
||||
const html=settingsMarkup({userId:8,nickName:'用户 <script>',introduction:'简介 <img>',avatar:'https://cdn.example/avatar.webp',isRegister:true,hasPassword:true});
|
||||
for(const text of ['member-settings-card','member-nickname-editor','id="member-nickname-form"','账号与安全','data-member-settings="bind"','data-auth-mode="register"','data-member-settings="switch"','data-auth-mode="switch"','ui-chevron-right','maxlength="20"','支持 1–20 个字符'])assert.ok(html.includes(text),text);
|
||||
assert.ok(html.includes('value="用户 <script>"'));assert.ok(html.includes('data-bind-state>已绑定'));
|
||||
assert.ok(settingsMarkup({userId:9,nickName:'游客',type:0,isRegister:false,hasPassword:false}).includes('data-bind-state>未绑定'));
|
||||
assert.ok(settingsMarkup({userId:10,nickName:'会员',type:1}).includes('data-bind-state>已绑定'));
|
||||
assert.ok(!html.includes('个人简介'));assert.ok(!html.includes('data-logout'));
|
||||
assert.ok(!html.includes('修改密码'));assert.ok(!html.includes('id="security-form"'));assert.ok(!html.includes('保存密码'));
|
||||
assert.ok(html.includes('class="member-settings-avatar"'));assert.ok(html.includes('alt="当前头像"'));assert.ok(html.includes('data-src="https://cdn.example/avatar.webp"'));
|
||||
assert.ok(!html.includes('更换头像'));assert.ok(!html.includes('member-avatar-file'));assert.ok(!html.includes('data-avatar-action'));
|
||||
});
|
||||
|
||||
test('feedback options map to the real API type and keep environment details with actual attachments',()=>{
|
||||
const html=feedbackMarkup();
|
||||
for(const text of ['feedback-reference','feedback-options','feedback-panel','feedback-description-wrap','feedback-upload-box','feedback-submit-button'])assert.ok(html.includes(text),text);
|
||||
assert.ok(!html.includes('video/mp4'));assert.ok(!html.includes('保存到本地'));
|
||||
const data=new FormData();for(const [key,value] of Object.entries({category:'登录异常',content:'播放时一直缓冲,无法正常观看视频。',contact:' me@example.com ',network:'Wi-Fi',device:'iPhone'}))data.set(key,value);
|
||||
const body=feedbackPayload(data,['/files/real.png']);assert.equal(body.type,3);assert.equal(body.contact,'me@example.com');assert.deepEqual(body.screenshots,['/files/real.png']);
|
||||
assert.ok(body.content.includes('网络类型:Wi-Fi'));assert.ok(body.content.includes('设备类型:iPhone'));
|
||||
data.set('category','无法播放');assert.equal(feedbackPayload(data).type,2);data.set('category','其他问题');assert.equal(feedbackPayload(data).type,4);data.set('category','闪退 / 崩溃');assert.equal(feedbackPayload(data).type,4);
|
||||
data.set('content','长'.repeat(500));assert.equal(feedbackPayload(data).content,'长'.repeat(500));
|
||||
data.set('content','中'.repeat(490));assert.equal(feedbackPayload(data).content.length,500);
|
||||
data.set('content','太短');assert.throws(()=>feedbackPayload(data),/10–500/);
|
||||
});
|
||||
|
||||
class Element extends EventTarget {
|
||||
nodes=new Map();dataset={};innerHTML='';textContent='';disabled=false;value='';files=[];
|
||||
querySelector(selector){return this.nodes.get(selector)||null;}
|
||||
querySelectorAll(){return [];}
|
||||
reportValidity(){return true;}
|
||||
reset(){this.resetCount=(this.resetCount||0)+1;}
|
||||
}
|
||||
function emit(element,type){element.dispatchEvent(new Event(type,{cancelable:true}));}
|
||||
|
||||
test('nickname settings save the nickname once and keep an existing introduction',async()=>{
|
||||
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
|
||||
const controller=new AbortController(),root=new Element(),profile=new Element(),status=new Element();
|
||||
const values=new Map([[profile,{nickname:'新昵称'}]]);
|
||||
profile.nodes.set('[type="submit"]',new Element());profile.nodes.set('#member-nickname-status',status);
|
||||
root.nodes.set('#member-nickname-form',profile);
|
||||
const calls=[];let finish,refreshes=0,routes=0;
|
||||
const app={signal:controller.signal,home:{},api:{ensureSession:async()=>({userId:8,introduction:'原简介'}),get:async()=>({}),post:async(path,body)=>{calls.push({path,body});if(calls.length===1)await new Promise(resolve=>finish=resolve);},refreshUser:async()=>refreshes++},notify(){},async route(){routes++;}};
|
||||
globalThis.document={getElementById:()=>root};globalThis.FormData=class extends OldFormData{constructor(form){super();if(form)for(const [key,value] of Object.entries(values.get(form)))this.set(key,value);}};
|
||||
try{
|
||||
await renderMember(app,'settings',1,()=>'/member/settings/');
|
||||
emit(profile,'submit');emit(profile,'submit');assert.equal(calls.length,1);assert.equal(profile.querySelector('[type="submit"]').disabled,true);
|
||||
finish();await flush();assert.deepEqual(calls[0],{path:'auth/profile',body:{nickName:'新昵称',introduction:'原简介'}});assert.equal(refreshes,1);assert.equal(routes,1);
|
||||
}finally{controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;}
|
||||
});
|
||||
|
||||
function feedbackFixture(post){
|
||||
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
|
||||
const controller=new AbortController(),root=new Element(),form=new Element(),input=new Element(),list=new Element(),status=new Element(),error=new Element(),content=new Element(),count=new Element(),submit=new Element();
|
||||
const values={content:'播放视频时持续缓冲,无法正常观看。',category:'无法播放',contact:'me@example.com',network:'Wi-Fi'};
|
||||
root.nodes.set('#feedback-form',form);
|
||||
for(const [selector,node] of [['#feedback-files',input],['#feedback-file-list',list],['#feedback-status',status],['#feedback-file-error',error],['[name="content"]',content],['#feedback-count',count],['[type="submit"]',submit]])form.nodes.set(selector,node);
|
||||
globalThis.document={getElementById:()=>root};globalThis.FormData=class extends OldFormData{constructor(form){super();if(form)for(const [key,value] of Object.entries(values))this.set(key,value);}};
|
||||
const calls=[];renderFeedbackPage({signal:controller.signal,api:{post:async(path,body)=>{calls.push({path,body});return post(path,body);}}});
|
||||
return {controller,form,input,list,status,error,submit,calls,dispose(){controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;}};
|
||||
}
|
||||
|
||||
test('reference feedback form uploads images only on submit and blocks duplicate feedback writes',async()=>{
|
||||
let finish;const f=feedbackFixture(path=>path==='upload/image'?new Promise(resolve=>finish=resolve):Promise.resolve({}));
|
||||
try{
|
||||
f.input.files=[new File(['image'],'shot.png',{type:'image/png'})];emit(f.input,'change');assert.equal(f.calls.length,0);assert.ok(f.list.innerHTML.includes('shot.png'));
|
||||
emit(f.form,'submit');emit(f.form,'submit');assert.equal(f.calls.length,1);assert.equal(f.calls[0].path,'upload/image');assert.equal(f.submit.disabled,true);
|
||||
finish({sourceNameFs:'/uploaded/shot.png'});await flush();
|
||||
assert.equal(f.calls.length,2);assert.equal(f.calls[1].path,'feedback/add');assert.deepEqual(f.calls[1].body.screenshots,['/uploaded/shot.png']);assert.equal(f.calls[1].body.type,2);
|
||||
assert.equal(f.form.resetCount,1);assert.match(f.status.textContent,/反馈已提交/);assert.equal(f.submit.disabled,false);
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('later feedback images are kept alongside earlier ones until three are selected',()=>{
|
||||
const f=feedbackFixture(async()=>({}));
|
||||
const image=(name)=>new File(['image'],name,{type:'image/png'});
|
||||
try{
|
||||
f.input.files=[image('one.png')];emit(f.input,'change');
|
||||
f.input.files=[image('two.png')];emit(f.input,'change');
|
||||
assert.ok(f.list.innerHTML.includes('one.png'));assert.ok(f.list.innerHTML.includes('two.png'));assert.equal(f.error.textContent,'');
|
||||
f.input.files=[image('three.png'),image('four.png')];emit(f.input,'change');
|
||||
assert.match(f.error.textContent,/最多上传 3 张/);assert.ok(!f.list.innerHTML.includes('three.png'));assert.ok(f.list.innerHTML.includes('one.png'));
|
||||
f.input.files=[image('three.png')];emit(f.input,'change');
|
||||
assert.equal(f.error.textContent,'');assert.ok(f.list.innerHTML.includes('three.png'));assert.equal(f.input.disabled,true);
|
||||
f.list.closest=()=>({dataset:{removeFeedbackFile:'0'}});
|
||||
f.list.dispatchEvent(new Event('click'));
|
||||
assert.ok(!f.list.innerHTML.includes('one.png'));assert.ok(f.list.innerHTML.includes('two.png'));assert.equal(f.input.disabled,false);
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('unsupported files, failed uploads and navigation cannot silently submit feedback',async()=>{
|
||||
const failed=feedbackFixture(async()=>({}));
|
||||
try{
|
||||
failed.input.files=[new File(['bad'],'clip.mp4',{type:'video/mp4'})];emit(failed.input,'change');assert.match(failed.error.textContent,/JPG/);assert.equal(failed.calls.length,0);
|
||||
failed.input.files=[new File(['valid'],'shot.png',{type:'image/png'})];emit(failed.input,'change');emit(failed.form,'submit');await flush();
|
||||
assert.deepEqual(failed.calls.map(call=>call.path),['upload/image']);assert.match(failed.status.textContent,/上传失败/);assert.equal(failed.submit.disabled,false);assert.equal(failed.form.resetCount,undefined);
|
||||
}finally{failed.dispose();}
|
||||
let finish;const late=feedbackFixture(()=>new Promise(resolve=>finish=resolve));
|
||||
try{
|
||||
late.input.files=[new File(['valid'],'shot.png',{type:'image/png'})];emit(late.input,'change');emit(late.form,'submit');late.controller.abort();finish({sourceNameFs:'/uploaded/late.png'});await flush();
|
||||
assert.deepEqual(late.calls.map(call=>call.path),['upload/image']);assert.equal(late.form.resetCount,undefined);
|
||||
}finally{late.dispose();}
|
||||
test('member, settings and feedback client modules are removed from the site',async()=>{
|
||||
const {access}=await import('node:fs/promises');
|
||||
for(const file of ['account.ts','feedback.ts','header-history.ts','unlock.ts','analytics.ts'])await assert.rejects(access(new URL('../src/lib/client/'+file,import.meta.url)),file);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {linkShared} from './helpers/shared-modules.mjs';
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -7,7 +8,7 @@ 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');
|
||||
return 'data:text/javascript;base64,'+Buffer.from(linkShared(replace(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
|
||||
}
|
||||
const routingUrl=await moduleUrl('../src/lib/routing.ts');
|
||||
const {routeFromSegments,parseSitePath,paginationPath,internalPagePath,searchPath}=await import(routingUrl);
|
||||
@@ -15,14 +16,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 trackingUrl=await moduleUrl('../src/lib/tracking.ts');
|
||||
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../tracking'",JSON.stringify(trackingUrl)));
|
||||
const {renderMember,renderWallet}=await import(accountUrl);
|
||||
|
||||
test('every paginated page type resolves directly from the page suffix',()=>{
|
||||
const keyword='都市 / page/7/?#%2F';
|
||||
const bases=['/shorts/','/topics/','/topics/7/','/theme/8/','/tag/9/','/config/31/','/rank/','/rank/hot/','/rank/new/','/rank/follow/','/search/'+encodeURIComponent(keyword)+'/', '/member/favorites/','/member/history/','/member/likes/','/member/purchased/','/info/deposit/','/info/deposit/vip/','/detail/23456/','/play/23456/90001/'];
|
||||
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+'/';
|
||||
@@ -37,7 +34,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/','/detail/23456/','/play/23456/90001/'])assert.equal(parseSitePath(base+'page/2/'),null,base);
|
||||
for(const base of ['/member/center/','/member/favorites/','/info/deposit/','/info/feedback/'])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));
|
||||
});
|
||||
@@ -50,7 +48,6 @@ 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/');
|
||||
});
|
||||
|
||||
test('pagination renders real page links with correct boundaries, current page, and bounded page numbers',()=>{
|
||||
@@ -94,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,140 +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 trackingUrl=await moduleUrl('../src/lib/tracking.ts');
|
||||
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../tracking'",JSON.stringify(trackingUrl)));
|
||||
const {paymentDialogMarkup,renderWallet}=await import(accountUrl);
|
||||
const coinPackage={id:31,name:'金币套餐',packageType:2,price:10000,baseCoin:100,giftCoin:999,totalCoin:1099,payMethods:[{type:'alipay',typeName:'支付宝'},{type:'wechat',typeName:'微信'}]};
|
||||
const flush=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
|
||||
test('confirmation displays actual base/gift/total coins and the price in yuan with original payment icons',()=>{
|
||||
const html=paymentDialogMarkup(coinPackage,false);
|
||||
assert.ok(html.includes('<strong>100 金币</strong>'));assert.ok(html.includes('赠送 999 金币 · 合计 1099 金币'));
|
||||
assert.ok(html.includes('<b>¥100</b>'));assert.ok(html.includes('5652fcead17188e8.svg'));assert.ok(html.includes('2bead9ea5c435c98.svg'));
|
||||
assert.ok(!html.includes('7848816d05aa8591.svg'));assert.ok(!html.includes('type="radio"'));
|
||||
assert.ok(html.includes('name="payMode" value="alipay"'));assert.ok(html.includes('class="wallet-payment-title"'));
|
||||
const derived=paymentDialogMarkup({...coinPackage,baseCoin:undefined,price:12345},false);
|
||||
assert.ok(derived.includes('<strong>100 金币</strong>'));assert.ok(derived.includes('<b>¥123.45</b>'));
|
||||
});
|
||||
|
||||
test('VIP and missing payment data never introduce demo bonuses or unconfigured channels',()=>{
|
||||
const vip=paymentDialogMarkup({id:7,name:'月卡 <script>',packageType:1,price:3000},true);
|
||||
assert.ok(vip.includes('会员畅享'));assert.ok(vip.includes('月卡 <script>'));assert.ok(!vip.includes('赠送 999'));
|
||||
assert.ok(vip.includes('暂无可用支付方式'));assert.match(vip,/<button class="wallet-submit wallet-payment-go" type="submit" disabled>/);
|
||||
assert.ok(!vip.includes('data-wallet-method='));
|
||||
const unknown=paymentDialogMarkup({...coinPackage,payMethods:[null,{type:'',typeName:'空'},{type:'union',typeName:'银联 <img>'},{type:'union',typeName:'重复'}]},false);
|
||||
assert.equal((unknown.match(/data-wallet-method=/g)||[]).length,1);assert.ok(unknown.includes('银联 <img>'));assert.ok(unknown.includes('wallet-pay-logo--other'));
|
||||
});
|
||||
|
||||
class Element extends EventTarget {
|
||||
dataset={};attrs=new Map();nodes=new Map();lists=new Map();innerHTML='';textContent='';disabled=false;isConnected=true;value='';
|
||||
querySelector(selector){return this.nodes.get(selector)||null;}
|
||||
querySelectorAll(selector){return this.lists.get(selector)||[];}
|
||||
setAttribute(key,value){this.attrs.set(key,String(value));}
|
||||
getAttribute(key){return this.attrs.get(key)||null;}
|
||||
closest(){return this;}
|
||||
reportValidity(){return true;}
|
||||
}
|
||||
function emit(element,type,target=element){const event=new Event(type,{cancelable:true});Object.defineProperty(event,'target',{value:target});element.dispatchEvent(event);}
|
||||
async function fixture({vip=false,post=async()=>({payUrl:'https://pay.example/checkout',tradeNo:'order-1'}),methods=coinPackage.payMethods,packages,transactions={list:[],totalCount:0},transactionError=false,balance=90,openDialog=true}={}){
|
||||
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
|
||||
const controller=new AbortController(),root=new Element(),dialog=new Element(),form=new Element(),hidden=new Element(),confirm=new Element(),error=new Element(),refresh=new Element();
|
||||
const item={...coinPackage,payMethods:methods};hidden.value=methods[0]?.type||'';
|
||||
const items=packages??[item],pay=new Element(),selection=new Element();pay.dataset.walletAction='pay';
|
||||
const packageButtons=items.map((item,index)=>{const button=new Element();button.dataset.buyPackage=String(item.id);button.setAttribute('aria-pressed',String(index===0));return button;});
|
||||
root.nodes.set('[data-wallet-action="pay"]',pay);root.nodes.set('#wallet-selection',selection);root.lists.set('[data-buy-package]',packageButtons);
|
||||
const choices=methods.map(method=>{const button=new Element();button.dataset.walletMethod=method.type;button.nodes.set('.wallet-method-check',new Element());return button;});
|
||||
form.nodes.set('[name="payMode"]',hidden);form.nodes.set('[type="submit"]',confirm);form.nodes.set('.api-error',error);form.nodes.set('[data-payment-refresh]',refresh);form.lists.set('[data-wallet-method]',choices);
|
||||
dialog.nodes.set('form',form);dialog.open=false;dialog.showModal=()=>dialog.open=true;dialog.close=()=>dialog.open=false;
|
||||
const posts=[];let refreshes=0,routes=0;
|
||||
const user={recharge:balance};
|
||||
const app={signal:controller.signal,api:{user,ensureSession:async()=>user,get:async path=>{if(path==='recharge/package/list')return {coinList:items,vipList:items};if(transactionError)throw new Error('offline');return transactions;},post:async(path,body)=>{posts.push({path,body});return post();},refreshUser:async()=>refreshes++},notify(){},async route(){routes++;}};
|
||||
globalThis.document={getElementById:id=>id==='info-content'?root:dialog};
|
||||
globalThis.FormData=class extends OldFormData{constructor(form){super();this.append('payMode',form.querySelector('[name="payMode"]').value);}};
|
||||
await renderWallet(app,vip,1,()=>'/info/deposit/');
|
||||
if(openDialog)emit(root,'click',pay);
|
||||
return {app,root,dialog,form,hidden,confirm,error,refresh,choices,posts,pay,selection,packageButtons,get refreshes(){return refreshes;},get routes(){return routes;},dispose(){controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;}};
|
||||
}
|
||||
|
||||
test('recharge page uses the HTML balance, package and transaction layout with actual API values',async()=>{
|
||||
const f=await fixture({openDialog:false,balance:2400,packages:[{...coinPackage,baseCoin:250,giftCoin:25,totalCoin:275,price:12345}],transactions:{list:[
|
||||
{title:'解锁 <短剧>',createdAt:'2026-10-20T13:24:00+08:00',flowDir:-1,amount:30,changeAmount:0},
|
||||
{tranTypeName:'充值到账',createdAt:'2026-10-20 12:00:00',flowDir:1,amount:100,changeAmount:125},
|
||||
{remark:'余额校验',flowDir:1,amount:0,coin:999},
|
||||
],totalCount:3}});
|
||||
try{
|
||||
const html=f.root.innerHTML;
|
||||
for(const className of ['wallet-balance-card','wallet-balance-summary','wallet-usage','wallet-pack-panel','wallet-pack-heading','wallet-package-coin','wallet-double-badge','wallet-mobile-selected','wallet-pay-area','wallet-transaction-list'])assert.ok(html.includes(className),className);
|
||||
assert.ok(html.includes('<strong>2,400</strong><b>金币</b>'));assert.ok(html.includes('>250金币</span>'));assert.ok(html.includes('>赠送 25金币</span>'));assert.ok(html.includes('立即支付 ¥123.45'));
|
||||
assert.ok(html.includes('已选择 250金币 + 赠送25金币'));assert.ok(html.includes('aria-pressed="true"'));
|
||||
assert.ok(html.includes('解锁 <短剧>'));assert.ok(html.includes('2026/10/20 13:24'));assert.ok(html.includes('datetime="2026-10-20T05:24:00.000Z"'));
|
||||
assert.ok(html.includes('expense">−30</span>'));assert.ok(html.includes('income">+125</span>'));assert.ok(html.includes('income">0</span>'));
|
||||
assert.ok(html.includes('href="/info/feedback/"'));assert.ok(!html.includes('模拟'));assert.ok(!html.includes('首次充值赠送10%'));assert.ok(!html.includes('1,280'));
|
||||
assert.equal(f.dialog.open,false);assert.equal(f.posts.length,0);
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('package click only updates selection and the pay action confirms the selected package ID',async()=>{
|
||||
const second={...coinPackage,id:84,baseCoin:300,giftCoin:0,totalCoin:300,price:4599};
|
||||
const f=await fixture({openDialog:false,packages:[coinPackage,second]});
|
||||
try{
|
||||
emit(f.root,'click',f.packageButtons[1]);
|
||||
assert.equal(f.dialog.open,false);assert.equal(f.posts.length,0);
|
||||
assert.equal(f.packageButtons[0].getAttribute('aria-pressed'),'false');assert.equal(f.packageButtons[1].getAttribute('aria-pressed'),'true');
|
||||
assert.equal(f.pay.textContent,'立即支付 ¥45.99');assert.equal(f.selection.textContent,'已选择 300金币 · 点击后选择支付方式');
|
||||
emit(f.root,'click',f.pay);assert.equal(f.dialog.open,true);assert.ok(f.dialog.innerHTML.includes('<strong>300 金币</strong>'));assert.ok(f.dialog.innerHTML.includes('<b>¥45.99</b>'));
|
||||
emit(f.form,'submit');await flush();assert.deepEqual(f.posts,[{path:'coin/buy',body:{packageId:84,payMode:'alipay',sourcePageKey:'deposit',sourcePageName:'充值中心'}}]);
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('empty packages and unavailable transactions show empty states without sample data or an order',async()=>{
|
||||
const f=await fixture({openDialog:false,packages:[],transactionError:true,balance:0});
|
||||
try{
|
||||
assert.ok(f.root.innerHTML.includes('<strong>0</strong><b>金币</b>'));assert.ok(f.root.innerHTML.includes('暂无可购买套餐'));assert.ok(f.root.innerHTML.includes('余额明细暂不可用,请稍后刷新'));
|
||||
assert.match(f.root.innerHTML, /data-wallet-action="pay" disabled/);assert.ok(!f.root.innerHTML.includes('data-buy-package='));assert.ok(!f.root.innerHTML.includes('wallet-transaction-row'));
|
||||
emit(f.root,'click',f.pay);assert.equal(f.dialog.open,false);assert.equal(f.posts.length,0);
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('selecting a styled method updates the actual order payload and duplicate submission is blocked',async()=>{
|
||||
let finish;const f=await fixture({post:()=>new Promise(resolve=>finish=resolve)});
|
||||
try{
|
||||
assert.equal(f.dialog.open,true);assert.equal(f.posts.length,0);
|
||||
emit(f.form,'click',f.choices[1]);assert.equal(f.hidden.value,'wechat');assert.equal(f.choices[1].getAttribute('aria-pressed'),'true');assert.equal(f.choices[1].querySelector('.wallet-method-check').textContent,'✓');
|
||||
emit(f.form,'submit');emit(f.form,'submit');assert.deepEqual(f.posts,[{path:'coin/buy',body:{packageId:31,payMode:'wechat',sourcePageKey:'deposit',sourcePageName:'充值中心'}}]);
|
||||
assert.equal(f.confirm.disabled,true);emit(f.form,'click',f.choices[0]);assert.equal(f.hidden.value,'wechat');
|
||||
finish({payUrl:'https://pay.example/checkout',tradeNo:'order-1'});await flush();
|
||||
assert.ok(f.form.innerHTML.includes('打开支付页面'));assert.equal(f.refreshes,0);assert.equal(f.app.api.user.recharge,90);
|
||||
emit(f.refresh,'click');await flush();assert.equal(f.refreshes,1);assert.equal(f.dialog.open,false);assert.equal(f.routes,1);
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('VIP uses its existing endpoint and an invalid payment URL leaves the form available to retry',async()=>{
|
||||
const f=await fixture({vip:true,post:async()=>({payUrl:'javascript:alert(1)'})});
|
||||
try{
|
||||
emit(f.form,'submit');await flush();assert.deepEqual(f.posts,[{path:'vip/pay',body:{packageId:31,payMode:'alipay',sourcePageKey:'deposit',sourcePageName:'充值中心'}}]);
|
||||
assert.match(f.error.textContent,/有效地址/);assert.equal(f.confirm.disabled,false);assert.equal(f.choices[0].disabled,false);assert.equal(f.refreshes,0);
|
||||
assert.ok(!f.form.innerHTML.includes('javascript:'));
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('missing methods cannot create an order and a replaced form ignores late order responses',async()=>{
|
||||
const empty=await fixture({methods:[]});
|
||||
try{emit(empty.form,'submit');assert.equal(empty.posts.length,0);}finally{empty.dispose();}
|
||||
let finish;const late=await fixture({post:()=>new Promise(resolve=>finish=resolve)});
|
||||
try{
|
||||
emit(late.form,'submit');late.form.isConnected=false;finish({payUrl:'https://pay.example',tradeNo:'old-order'});await flush();
|
||||
assert.equal(late.form.innerHTML,'');assert.equal(late.refreshes,0);
|
||||
}finally{late.dispose();}
|
||||
});
|
||||
+50
-237
@@ -1,3 +1,4 @@
|
||||
import {linkShared} from './helpers/shared-modules.mjs';
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -10,7 +11,7 @@ import {seoUrl} from './helpers/seo.mjs';
|
||||
async function moduleUrl(path,transform=code=>code){
|
||||
const source=await readFile(new URL(path,import.meta.url),'utf8');
|
||||
const {outputText}=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||||
return 'data:text/javascript;base64,'+Buffer.from(transform(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+new URL(path,import.meta.url).pathname).toString('base64');
|
||||
return 'data:text/javascript;base64,'+Buffer.from(linkShared(transform(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+new URL(path,import.meta.url).pathname).toString('base64');
|
||||
}
|
||||
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const playbackModuleUrl=await moduleUrl('../src/lib/client/playback.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
|
||||
@@ -19,7 +20,6 @@ const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace
|
||||
const appDownloadUrl=await moduleUrl('../src/lib/app-download.ts',code=>code.replace(/from ['"]\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
|
||||
const downloadUrl=await moduleUrl('../src/lib/client/download.ts',code=>code.replace(/from ['"]\.\.\/app-download['"]/,`from '${appDownloadUrl}'`));
|
||||
const detailUrl=await moduleUrl('../src/lib/client/detail.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`).replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`));
|
||||
const unlockUrl=await moduleUrl('../src/lib/client/unlock.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`).replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`));
|
||||
const feedbackUrl=await moduleUrl('../src/lib/client/request-feedback.ts');
|
||||
const markupUrl=await moduleUrl('../src/lib/client/player-markup.ts',code=>code
|
||||
.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`)
|
||||
@@ -39,7 +39,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);
|
||||
@@ -391,83 +390,41 @@ test('play page like follows the drama status and writes playlet/like/set',async
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('comment likes update their local count after success without refetching the comment list',async()=>{
|
||||
const f=playerFixture(),button=new Element('BUTTON'),label=new Element('SPAN');let dispose,resolve;
|
||||
f.page.nodes.delete('video');button.dataset.commentLike='8';button.dataset.commentCount='9';button.setAttribute('aria-pressed','false');label.textContent='♡ 9';button.nodes.set('[data-request-label]',label);
|
||||
f.app.api.post=(path,body)=>{f.posts.push({path,body});return new Promise(yes=>resolve=yes);};
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);await flush();const reads=f.getCalls.filter(call=>call.path==='comment/list').length;
|
||||
f.click(button);f.click(button);assert.equal(f.posts.length,1);assert.equal(label.textContent,'请求中');assert.equal(button.disabled,true);
|
||||
resolve(null);await flush();assert.equal(label.textContent,'♥ 10');assert.equal(button.getAttribute('aria-pressed'),'true');assert.equal(button.disabled,false);
|
||||
f.click(button);assert.equal(f.posts.at(-1).body.isLike,false);resolve({countLike:9});await flush();assert.equal(label.textContent,'♡ 9');
|
||||
assert.equal(f.getCalls.filter(call=>call.path==='comment/list').length,reads);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('comment submission displays sending feedback, blocks duplicate submits and retains text when the write fails',async()=>{
|
||||
const f=playerFixture(),input=new Element('TEXTAREA'),button=new Element('BUTTON'),context=new Element(),error=new Element();let dispose,resolve,reject;
|
||||
f.page.nodes.delete('video');button.textContent='发表评论';input.value='评论内容';
|
||||
const form=f.page.querySelector('form');form.elements={namedItem:()=>input};form.nodes.set('[type="submit"]',button);f.page.nodes.set('#reply-context',context);f.page.nodes.set('#comment-error',error);
|
||||
const submit=()=>{const event=new Event('submit',{cancelable:true});Object.defineProperty(event,'target',{value:form});form.dispatchEvent(event);};
|
||||
f.app.api.post=(path,body)=>{f.posts.push({path,body});return new Promise((yes,no)=>{resolve=yes;reject=no;});};
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);await flush();
|
||||
submit();submit();assert.equal(f.posts.length,1);assert.equal(button.textContent,'发送中');assert.equal(button.disabled,true);
|
||||
reject(new Error('发送失败'));await flush();assert.equal(input.value,'评论内容');assert.equal(button.textContent,'发表评论');assert.equal(error.textContent,'发送失败');assert.equal(button.disabled,false);assert.deepEqual(f.notifications,[]);
|
||||
assert.deepEqual(f.posts[0],{path:'comment/send',body:{objectId:23456,objectType:10,text:'评论内容',parentsId:0,replyCommentId:0}});
|
||||
submit();resolve({});await flush();assert.equal(input.value,'');assert.equal(button.textContent,'发表评论');assert.equal(button.disabled,false);assert.equal(error.textContent,'');assert.deepEqual(f.notifications,['评论已发表']);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('comment pagination uses page links and URL-provided page and sorting',async()=>{
|
||||
const f=playerFixture({allowed:true,commentData:{list:[],totalCount:95}});let dispose;
|
||||
const href=(page,sort)=>`/play/23456/90042/${page>1?`page/${page}/`:''}${sort===1?'?commentSort=1':''}`;
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456,90042,false,{page:3,commentSort:1,pageHref:href});await flush();
|
||||
const request=f.getCalls.find(call=>call.path==='comment/list');
|
||||
assert.equal(request.query.objectId,23456);assert.equal(request.query.objectType,10);
|
||||
assert.equal(request.query.pageNum,3);assert.equal(request.query.sortOrder,1);assert.equal(request.query.pageSize,10);
|
||||
const html=f.page.querySelector('.play-comment-list').innerHTML;
|
||||
assert.ok(html.includes('href="/play/23456/90042/page/4/?commentSort=1"'));
|
||||
assert.ok(!html.includes('data-comment-page'));assert.ok(!html.includes('data-page-number'));
|
||||
assert.ok(f.page.innerHTML.includes('data-comment-sort="1" aria-pressed="true"'));
|
||||
const sort=new Element('BUTTON');sort.dataset.commentSort='2';f.click(sort);await flush();
|
||||
assert.equal(f.navigations.at(-1),'/play/23456/90042/');
|
||||
assert.equal(f.getCalls.filter(call=>call.path==='comment/list').length,1);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('slug playback links survive client episode rendering, next controls, comments and confirmed unlocking with numeric write IDs',async()=>{
|
||||
const f=playerFixture({allowed:true,detail:{slug:'city-story',tagIds:[9],tagSlugs:['urban'],tagNames:['都市']},commentData:{list:[],totalCount:25}});let dispose;
|
||||
test('slug playback links survive client episode rendering and next controls; locked tiles still link to their play page',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.innerHTML.includes('href="/play/city-story/90042/"'));
|
||||
assert.ok(!f.page.innerHTML.includes('/page/2/'));
|
||||
const next=new Element('BUTTON');next.dataset.playAction='next';f.click(next);await flush();
|
||||
assert.equal(f.navigations.at(-1),'/play/city-story/91200/');
|
||||
}finally{dispose?.();f.dispose();}
|
||||
const paid=playerFixture({detail:{slug:'city-story'},chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId)});let stop;
|
||||
paid.page.nodes.delete('video');
|
||||
try{
|
||||
stop=await renderPlayer(paid.app,23456);paid.click(lockedButton(90042));await flush();
|
||||
paid.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
|
||||
assert.deepEqual(paid.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}});
|
||||
assert.equal(paid.navigations.at(-1),'/play/city-story/90042/');
|
||||
stop=await renderPlayer(paid.app,23456);await flush();
|
||||
const html=paid.page.innerHTML;
|
||||
assert.ok(html.includes('class="film-tile is-locked" href="/play/city-story/90042/"'));
|
||||
assert.ok(html.includes('aria-label="第 2 集,请在 App 观看"'));
|
||||
assert.ok(!html.includes('data-locked-chapter'));assert.ok(!html.includes('金币'));
|
||||
assert.equal(paid.posts.length,0);
|
||||
}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('locked chapters offer only the free preview; choosing main shows the App gate and never unlocks or reports',async()=>{
|
||||
const f=playerFixture();let dispose;
|
||||
const gate=new Element();gate.hidden=true;f.box.nodes.set('.play-app-gate',gate);
|
||||
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);
|
||||
assert.ok(f.page.innerHTML.includes('本集请在 黄果短剧 App 观看'));assert.ok(f.page.innerHTML.includes('下载 App 继续观看'));
|
||||
assert.equal(f.box.dataset.playMode,'preview');assert.equal(f.video.src,'https://cdn.example/preview.mp4');assert.equal(f.video.plays,0);assert.equal(gate.hidden,true);
|
||||
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(gate.hidden,false);assert.equal(f.status.textContent,'请在 App 观看');
|
||||
await f.controlActions.toggle();assert.equal(f.posts.length,0);assert.equal(f.video.plays,1);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
@@ -486,18 +443,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 and let page buttons reach the page',async()=>{
|
||||
const source=await readFile(new URL('../public/scripts/site-runtime.js',import.meta.url),'utf8');
|
||||
const start=source.indexOf('function installPlayerControls('),end=source.indexOf('/* ---- sticky-search-header-script ---- */',start);
|
||||
const install=vm.runInNewContext(source.slice(start,end)+'\ninstallPlayerControls;', {document:{},emptyStateMarkup:()=>'<div>重试</div>'});
|
||||
@@ -514,55 +460,40 @@ test('actual player controls delegate play and retry to the media engine and all
|
||||
});
|
||||
|
||||
|
||||
test('detail overview keeps original action groups, actual chapter identifiers and styled comment replies',async()=>{
|
||||
const f=playerFixture({detail:{description:'接口简介',isNew:true,cover:'https://cdn.example/poster.webp'},commentData:{list:[{id:1,userName:'用户 <script>',avatar:'https://cdn.example/a.webp',text:'父评论',children:[{id:2,parentsId:1,replyCommentId:1,replyUserName:'用户 <script>',userAvatar:'https://cdn.example/b.webp',text:'回复'}]}],totalCount:2}});let dispose;
|
||||
f.app.api.user={recharge:90,avatar:'https://cdn.example/me.webp'};
|
||||
test('detail overview keeps the cinematic hero, actual chapter identifiers and recommended videos without comments',async()=>{
|
||||
const f=playerFixture({detail:{description:'接口简介',isNew:true,cover:'https://cdn.example/poster.webp'}});let dispose;
|
||||
f.page.nodes.delete('video');
|
||||
const related=[{id:23456,title:'当前剧'},{id:7,title:'推荐 <一>'},{id:8,title:'推荐二'}];
|
||||
f.app.api.get=(get=>async(path,query)=>path==='playlet/guess-like/list'?(f.getCalls.push({path,query}),{list:related}):get(path,query))(f.app.api.get);
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);await flush();
|
||||
assert.ok(f.page.innerHTML.includes('href="/play/23456/90001/"'));assert.ok(f.page.innerHTML.includes('series-action-bar'));
|
||||
assert.ok(f.page.innerHTML.includes('接口简介'));assert.ok(f.page.innerHTML.includes('class="play-official"'));
|
||||
const html=f.page.innerHTML;
|
||||
assert.ok(html.includes('href="/play/23456/90001/"'));assert.ok(html.includes('class="cine-hero"'));
|
||||
assert.ok(html.includes('接口简介'));assert.ok(html.includes('class="play-official"'));
|
||||
assert.ok(!f.getCalls.some(call=>call.path==='playlet/chapter/detail'));
|
||||
assert.ok(f.page.innerHTML.includes('class="play-compose-avatar"'));
|
||||
assert.ok(f.page.innerHTML.includes('data-src="https://cdn.example/me.webp"'));
|
||||
const comments=f.page.querySelector('.play-comment-list').innerHTML;
|
||||
assert.ok(comments.includes('class="play-comment-body"'));assert.ok(comments.includes('class="play-comment-replies"'));
|
||||
assert.ok(comments.includes('play-comment-item play-comment-child'));assert.ok(comments.includes('用户 <script>'));
|
||||
assert.ok(comments.includes('class="play-reply-target">回复 @用户 <script></span>'));
|
||||
assert.ok(comments.includes('class="play-comment-avatar"'));
|
||||
assert.ok(comments.includes('data-src="https://cdn.example/a.webp"'));
|
||||
assert.ok(comments.includes('data-src="https://cdn.example/b.webp"'));
|
||||
assert.ok(comments.includes('data-lazy-eager="1"'));
|
||||
assert.deepEqual(f.getCalls.find(call=>call.path==='playlet/guess-like/list').query,{playletId:23456,pageNum:1,pageSize:12});
|
||||
assert.ok(html.includes('<h2 id="play-recommend-title">推荐视频</h2>'));assert.ok(html.includes('看了《测试剧集》的人还在看'));
|
||||
assert.ok(html.includes('推荐 <一>'));assert.ok(!html.includes('href="/detail/23456/"'));
|
||||
for(const text of ['评论','play-comment','反馈','客服'])assert.ok(!html.includes(text),text);
|
||||
assert.ok(html.indexOf('play-recommend')<html.indexOf('play-official'));
|
||||
const like=new Element('BUTTON');like.dataset.engage='like';like.nodes.set('span',new Element());
|
||||
f.click(like);await flush();assert.deepEqual(f.posts.at(-1),{path:'playlet/like/set',body:{id:23456,isLike:true}});
|
||||
assert.equal(like.querySelector('span').textContent,'已点赞');
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('comment composer avatar follows the signed-in account avatar and falls back like the header',()=>{
|
||||
const withUser=playerMarkup({id:1,title:'剧'},[],{user:{userId:7,avatar:'https://cdn.example/account.webp'}});
|
||||
assert.ok(withUser.includes('class="play-compose-avatar"'));
|
||||
assert.ok(withUser.includes('data-src="https://cdn.example/account.webp"'));
|
||||
assert.ok(withUser.includes('src="/icon.svg"'));
|
||||
const guest=playerMarkup({id:1,title:'剧'},[],{user:null});
|
||||
assert.ok(guest.includes('class="play-compose-avatar"'));
|
||||
assert.ok(guest.includes('src="/icon.svg"'));
|
||||
assert.ok(!guest.includes('data-src='));
|
||||
});
|
||||
|
||||
test('episode segments keep their anchor across desktop/mobile layout changes and show original lock and playing markers',async()=>{
|
||||
test('the film strip lists every episode with current and locked tiles',async()=>{
|
||||
const chapterList=Array.from({length:130},(_,index)=>({id:90001+index,currentEpisode:index+1,canWatch:index<10,payType:index<10?0:1}));
|
||||
const f=playerFixture({allowed:true,chapterList});let dispose;
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456,90042);await flush();
|
||||
const grid=f.page.querySelector('.play-episode-grid'),segments=f.page.querySelector('.play-episode-pages');
|
||||
assert.ok(segments.innerHTML.includes('class="episode-page-track"'));assert.ok(grid.innerHTML.includes('class="episode-playing"'));
|
||||
assert.ok(grid.innerHTML.includes('class="coin-soft-star"'));
|
||||
const tab=new Element('BUTTON');tab.dataset.episodePage='2';f.click(tab);await flush();assert.ok(grid.innerHTML.includes('data-locked-chapter="90091"'));
|
||||
globalThis.matchMedia=()=>({matches:true});window.dispatchEvent(new Event('resize'));
|
||||
assert.ok(grid.innerHTML.includes('data-locked-chapter="90091"'));assert.ok(!grid.innerHTML.includes('data-locked-chapter="90106"'));
|
||||
globalThis.matchMedia=()=>({matches:false});window.dispatchEvent(new Event('resize'));
|
||||
assert.ok(grid.innerHTML.includes('data-locked-chapter="90091"'));assert.ok(grid.innerHTML.includes('data-locked-chapter="90130"'));
|
||||
const html=f.page.innerHTML;
|
||||
assert.ok(html.includes('class="film-strip"'));
|
||||
assert.equal((html.match(/class="film-tile[" ]/g)||[]).length,130);
|
||||
assert.ok(html.includes('class="film-tile is-current is-locked" href="/play/23456/90042/"'));assert.ok(html.includes('aria-current="true"'));
|
||||
assert.ok(html.includes('class="film-tile is-locked" href="/play/23456/90130/"'));
|
||||
assert.ok(!html.includes('class="film-tile is-locked" href="/play/23456/90010/"'));
|
||||
assert.ok(html.includes('>03</span>'));assert.ok(html.includes('>42</span>'));
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
@@ -577,145 +508,27 @@ const lockedList=[{id:90001,currentEpisode:1,canWatch:true},{id:90042,currentEpi
|
||||
const lockedChapter=(id=90042,extra={})=>({id,playletId:23456,currentEpisode:id===90042?2:3,needUnlock:true,payType:1,costCoins:12,singleCoins:30,balance:90,...extra});
|
||||
const lockedButton=id=>{const button=new Element('BUTTON');button.dataset.lockedChapter=String(id);return button;};
|
||||
|
||||
test('clicking a coin episode on detail opens the original dialog, fetches live pricing, and waits for confirmation before navigating',async()=>{
|
||||
const f=playerFixture({chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId),unlock:async()=>({balance:78})});let dispose;
|
||||
f.page.nodes.delete('video');
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);await flush();
|
||||
const grid=f.page.querySelector('.play-episode-grid').innerHTML;
|
||||
assert.ok(grid.includes('class="play-episode-locked" data-locked-chapter="90042"'));assert.ok(!grid.includes('href="/play/23456/90042/"'));
|
||||
f.click(lockedButton(90042));assert.equal(f.lock.open,true);assert.equal(f.lock.querySelector('.unlock-pay').disabled,true);
|
||||
f.click(lockedButton(90042));await flush();
|
||||
assert.equal(f.getCalls.filter(call=>call.path==='playlet/chapter/detail').length,1);
|
||||
assert.deepEqual(f.getCalls.find(call=>call.path==='playlet/chapter/detail').query,{playletId:23456,chapterId:90042});
|
||||
assert.equal(f.lock.querySelector('.unlock-cost').textContent,'12 金币');assert.equal(f.lock.querySelector('#episode-lock-balance').textContent,'90 金币');
|
||||
assert.match(f.lock.querySelector('#episode-lock-description').textContent,/第 2 集/);assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[]);
|
||||
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
|
||||
assert.deepEqual(f.posts,[{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}}]);
|
||||
assert.equal(f.app.api.user.recharge,78);assert.equal(f.refreshes,1);assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,['/play/23456/90042/']);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('a late chapter response cannot replace the newer episode selected for payment',async()=>{
|
||||
const pending=new Map();
|
||||
const f=playerFixture({chapterList:lockedList,loadChapter:query=>new Promise(resolve=>pending.set(query.chapterId,resolve))});let dispose;
|
||||
f.page.nodes.delete('video');
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));f.click(lockedButton(91200));
|
||||
pending.get(91200)(lockedChapter(91200,{costCoins:9}));await flush();
|
||||
pending.get(90042)(lockedChapter(90042,{costCoins:99}));await flush();
|
||||
assert.equal(f.lock.querySelector('.unlock-cost').textContent,'9 金币');assert.match(f.lock.querySelector('#episode-lock-description').textContent,/第 3 集/);
|
||||
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
|
||||
assert.deepEqual(f.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:91200,playletId:23456}});assert.deepEqual(f.navigations,['/play/23456/91200/']);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('closing a pending dialog or leaving the route prevents stale requests from reopening or submitting it',async()=>{
|
||||
for(const leave of [false,true]){
|
||||
let finish;const f=playerFixture({chapterList:lockedList,loadChapter:()=>new Promise(resolve=>finish=resolve)});let dispose;
|
||||
f.page.nodes.delete('video');
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));
|
||||
if(leave)f.dispose();else f.lock.close();
|
||||
finish(lockedChapter());await flush();
|
||||
assert.equal(f.lock.open,false);f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
|
||||
assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[]);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
}
|
||||
});
|
||||
|
||||
test('already permitted and VIP chapters follow fresh access results instead of charging coins',async()=>{
|
||||
for(const [data,path] of [[lockedChapter(90042,{canWatch:true}),'/play/23456/90042/'],[lockedChapter(90042,{needUnlock:false,needVip:true,payType:2}),'/info/deposit/vip/']]){
|
||||
const f=playerFixture({chapterList:lockedList,loadChapter:()=>data});let dispose;f.page.nodes.delete('video');
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
|
||||
assert.equal(f.lock.open,false);assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[path]);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
}
|
||||
});
|
||||
|
||||
test('a zero chapter balance shows the account coin balance',async()=>{
|
||||
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(90042,{balance:0})});let dispose;f.page.nodes.delete('video');
|
||||
f.app.api.user.recharge=1000;
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
|
||||
assert.equal(f.lock.querySelector('#episode-lock-balance').textContent,'1000 金币');
|
||||
assert.equal(f.lock.querySelector('.unlock-pay').textContent,'支付 12 金币并播放');
|
||||
assert.equal(f.refreshes,1);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('insufficient displayed balance offers recharge and never sends an unlock request',async()=>{
|
||||
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(90042,{balance:3})});let dispose;f.page.nodes.delete('video');
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
|
||||
assert.equal(f.lock.querySelector('.unlock-pay').textContent,'金币不足,去充值');
|
||||
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
|
||||
assert.equal(f.posts.length,0);assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,['/info/deposit/']);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('mismatched chapter information leaves payment disabled without navigating or charging',async()=>{
|
||||
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(91200)});let dispose;f.page.nodes.delete('video');
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
|
||||
assert.equal(f.lock.querySelector('.unlock-pay').disabled,true);assert.match(f.lock.querySelector('#episode-lock-error').textContent,/不匹配/);
|
||||
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[]);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('payment errors remain visible and retryable; balance and auth errors follow reference recovery',async()=>{
|
||||
const {ApiError}=await import(protocolUrl);
|
||||
for(const [error,path] of [[new Error('网络暂不可用'),null],[new ApiError('金币余额不足',400),'/info/deposit/'],[new ApiError('登录已过期',1000),'login']]){
|
||||
let attempts=0,authPrompts=0;
|
||||
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(),unlock:async()=>{attempts++;if(attempts===1)throw error;return {balance:78};}});let dispose;f.page.nodes.delete('video');f.app.openAuth=()=>authPrompts++;
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
|
||||
const pay=f.lock.querySelector('.unlock-pay');pay.dispatchEvent(new Event('click'));await flush();
|
||||
assert.equal(f.refreshes,0);
|
||||
if(path==='login'){assert.equal(authPrompts,1);assert.equal(f.lock.open,false);}
|
||||
else if(path){assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,[path]);}
|
||||
else{
|
||||
assert.equal(f.lock.open,true);assert.equal(f.lock.querySelector('#episode-lock-error').textContent,error.message);assert.equal(f.lock.querySelector('#episode-lock-error').hidden,false);assert.equal(pay.disabled,false);
|
||||
pay.dispatchEvent(new Event('click'));await flush();assert.equal(f.posts.length,2);assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,['/play/23456/90042/']);
|
||||
}
|
||||
}finally{dispose?.();f.dispose();}
|
||||
}
|
||||
});
|
||||
|
||||
test('a pending payment cannot be repeated or retargeted to a different episode',async()=>{
|
||||
let finish;const f=playerFixture({chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId),unlock:()=>new Promise(resolve=>finish=resolve)});let dispose;f.page.nodes.delete('video');
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
|
||||
const pay=f.lock.querySelector('.unlock-pay');pay.dispatchEvent(new Event('click'));pay.dispatchEvent(new Event('click'));f.click(lockedButton(91200));await flush();
|
||||
const cancel=new Event('cancel',{cancelable:true});f.lock.dispatchEvent(cancel);
|
||||
assert.equal(cancel.defaultPrevented,true);assert.equal(f.lock.querySelector('.unlock-close').disabled,true);
|
||||
assert.equal(f.posts.length,1);assert.equal(f.getCalls.filter(call=>call.path==='playlet/chapter/detail').length,1);
|
||||
finish({balance:78});await flush();assert.deepEqual(f.navigations,['/play/23456/90042/']);assert.equal(f.lock.open,false);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
|
||||
test('server player data excludes credentials and denied sources while retaining free episodes, comments and artwork',()=>{
|
||||
test('server player data excludes credentials and denied sources while retaining free episodes, 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:'回复',replyUserName:'甲',replyCommentId:1,parentsId:1,token:'child-secret'}]}],totalCount:12});
|
||||
const related=[{id:23456,title:'自身'},...Array.from({length:14},(_,index)=>({id:100+index,title:'相关剧集'+index,token:'related-secret'}))];
|
||||
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:related},[free,locked],locked);
|
||||
const json=JSON.stringify(initial);
|
||||
for(const value of ['detail-secret','chapter-secret','sdk-secret','ad-secret','related-secret','comment-secret','child-secret','secret-locked.mp4'])assert.ok(!json.includes(value),value);
|
||||
for(const value of ['detail-secret','chapter-secret','sdk-secret','ad-secret','related-secret','secret-locked.mp4'])assert.ok(!json.includes(value),value);
|
||||
assert.equal(initial.chapters[0].videoUrl,free.videoUrl);assert.equal(initial.chapter.canWatch,false);assert.equal(initial.chapter.needUnlock,true);
|
||||
assert.equal(initial.chapter.countLike,27);assert.equal(initial.chapter.ratio,0.5625);assert.equal(initial.chapter.seoInfo.title,'章节独立标题');
|
||||
assert.equal(initial.comments.list[0].text,'评论正文');assert.equal(initial.comments.list[0].children[0].text,'回复');assert.equal(initial.comments.list[0].children[0].replyUserName,'甲');assert.equal(initial.detail.advitise.nineGridList[0].images,'https://cdn.example/ad.webp');
|
||||
assert.equal(initial.comments,undefined);assert.equal(initial.detail.advitise.nineGridList[0].images,'https://cdn.example/ad.webp');
|
||||
assert.equal(initial.detail.relatedRecommendData.length,12);assert.ok(!initial.detail.relatedRecommendData.some(item=>item.id===23456));
|
||||
});
|
||||
|
||||
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:'首屏详情'},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();}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {linkShared} from './helpers/shared-modules.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
@@ -5,7 +6,7 @@ import ts from 'typescript';
|
||||
|
||||
async function moduleURL(path,replace=value=>value){
|
||||
const code=ts.transpileModule(await readFile(new URL(path,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText;
|
||||
return 'data:text/javascript;base64,'+Buffer.from(replace(code)).toString('base64');
|
||||
return 'data:text/javascript;base64,'+Buffer.from(linkShared(replace(code))).toString('base64');
|
||||
}
|
||||
const {PublicDataCache,publicCachePolicy}=await import(await moduleURL('../src/lib/api/public-cache.ts'));
|
||||
const protocol=await moduleURL('../src/lib/api/protocol.ts');
|
||||
@@ -48,7 +49,9 @@ test('cache bounds and endpoint policies keep private data and indefinite growth
|
||||
test('shared browser snapshot contains visible configuration and footer fields without SDK or unknown secrets',()=>{
|
||||
const data=publicSiteData({reportSdkConfig:'private-sdk-cipher',token:'private-token',config:{officialAppAddr:'https://app.test/download',telegram:'https://t.me/business',apiKey:'private-api-key'},advitise:{dialogList:[{id:'a',title:'广告',images:'https://ad.test/image',link:'https://ad.test',token:'private-ad-token'}],unknown:[{secret:'private-slot'}]}},{footerInfo:{brand:{title:'黄果短剧',content:'真实页脚',token:'private-brand-token'},contactList:[{name:'服务时间',value:'每天',token:'private-contact-token'}],navList:[{id:1,name:'规则',urlValue:'/ignored/',token:'private-nav-token',translationList:[{id:2,name:'关于我们',urlValue:'/richetext/关于我们',secret:'private-nav-child'}]}]}});
|
||||
const text=JSON.stringify(data);for(const secret of ['private-sdk-cipher','private-token','private-api-key','private-ad-token','private-slot','private-contact-token','private-nav-token','private-nav-child','private-brand-token'])assert.ok(!text.includes(secret));
|
||||
assert.equal(data.trackerConfigured,true);assert.equal(data.config.config.officialAppAddr,'https://app.test/download');assert.equal(data.footer.footerInfo.brand.title,'黄果短剧');assert.equal(data.footer.footerInfo.brand.content,'真实页脚');
|
||||
assert.equal('trackerConfigured' in data,false);assert.ok(!text.includes('t.me/business'));assert.deepEqual(data.footer.footerInfo.contactList,[]);assert.equal(data.config.config.officialAppAddr,'https://app.test/download');assert.equal(data.footer.footerInfo.brand.title,'黄果短剧');assert.equal(data.footer.footerInfo.brand.content,'真实页脚');
|
||||
assert.equal(data.footer.footerInfo.navList[0].translationList[0].name,'关于我们');assert.equal(data.footer.footerInfo.navList[0].translationList[0].urlValue,'/richetext/关于我们');
|
||||
assert.doesNotThrow(()=>publicSiteData({config:null,advitise:null},{footerInfo:null}));
|
||||
const filtered=publicSiteData({},{footerInfo:{navList:[{name:'帮助',translationList:[{name:'关于我们',urlValue:'/richtext/关于我们'},{name:'意见反馈',urlValue:'/info/feedback'},{name:'在线客服',urlValue:'https://help.test'},{name:'邮箱',urlValue:'mailto:a@b.test'},{name:'充值',urlValue:'/info/deposit/'}]}]}});
|
||||
assert.deepEqual(filtered.footer.footerInfo.navList[0].translationList.map(item=>item.name),['关于我们']);
|
||||
});
|
||||
|
||||
+27
-55
@@ -1,4 +1,5 @@
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {linkShared,sharedModule} from './helpers/shared-modules.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
@@ -18,7 +19,8 @@ const renderUrl='data:text/javascript;base64,'+Buffer.from(renderCode.replace("'
|
||||
const searchCode=ts.transpileModule(await readFile(new URL('../src/lib/client/search.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const search=await import('data:text/javascript;base64,'+Buffer.from(searchCode.replace("'../routing'",JSON.stringify(routingModuleUrl)).replace("'../api/protocol'",JSON.stringify(protocolUrl)).replace("'./render'",JSON.stringify(renderUrl))).toString('base64'));
|
||||
const infoCode=ts.transpileModule(await readFile(new URL('../src/lib/info-pages.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const infoPages=await import('data:text/javascript;base64,'+Buffer.from(infoCode).toString('base64'));
|
||||
const infoPages=await import('data:text/javascript;base64,'+Buffer.from(linkShared(infoCode)).toString('base64'));
|
||||
const brandModule=await sharedModule('brand');
|
||||
const siteNavigationCode=ts.transpileModule(await readFile(new URL('../src/lib/site-navigation.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const applicationFile=new URL('../src/lib/client/application.ts',import.meta.url);
|
||||
const applicationCode=ts.transpileModule(await readFile(applicationFile,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
@@ -38,6 +40,7 @@ function fixture(load=async()=>({list:[{id:1}],totalCount:72,name:'接口通用
|
||||
const exports={},channelExports={},siteNavigationExports={};
|
||||
const require=specifier=>{
|
||||
if(specifier==='../site-navigation')return siteNavigationExports;
|
||||
if(specifier==='../brand')return brandModule;
|
||||
if(specifier==='./routing')return routing;
|
||||
if(specifier==='./api/protocol')return protocol;
|
||||
if(specifier==='./channel')return channelExports;
|
||||
@@ -51,8 +54,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 +65,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 +75,29 @@ 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',()=>{
|
||||
test('about shows only the single business Telegram contact, ignoring backend mail, feedback and service channels',()=>{
|
||||
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'));
|
||||
assert.ok(page.html.includes('商务合作:<a href="https://t.me/jbd125"'));assert.ok(page.html.includes('Telegram @jbd125'));
|
||||
for(const text of ['用户反馈','feedback_group','business_team','mailto:','@example.com','ignored.example.com','客服'])assert.ok(!page.html.includes(text),text);
|
||||
});
|
||||
|
||||
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('the contact section is identical whatever the backend configures',()=>{
|
||||
const expected=infoPages.infoContactSection();
|
||||
for(const config of [{config:{telegram:'',tgGroup:''},newAddressInfo:{telegram:'@business_team',tgGroup:'https://t.me/feedback_group'}},{config:{telegram:'https://t.me/primary'}},{config:{gmailAddr:'help@example.com'}},{config:{telegramAdd:'https://t.me/legacy'}}])
|
||||
assert.equal(infoPages.infoContactSection(config),expected);
|
||||
assert.ok(expected.includes('href="https://t.me/jbd125"'));assert.ok(!expected.includes(' hidden'));
|
||||
});
|
||||
|
||||
test('configured contacts escape text and reject executable or malformed addresses',()=>{
|
||||
test('configured contact rows never reach the page, so executable addresses cannot be injected',()=>{
|
||||
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,
|
||||
{name:'数据链接',value:'data:text/html,<script>alert(1)</script>'},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'));
|
||||
for(const text of ['<script','<img','javascript:','data:text'])assert.ok(!html.includes(text),text);
|
||||
});
|
||||
|
||||
test('ranking data and native page links derive from the server route and preserve global positions',()=>{
|
||||
@@ -132,32 +124,12 @@ 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('cold unavailable member and wallet pages client-render after guest bootstrap',async()=>{
|
||||
for(const [path,handler,tab] of [['/member/center/','renderMember','center'],['/member/history/','renderMember','history'],['/info/deposit/','renderWallet',undefined],['/info/deposit/vip/','renderWallet',undefined],['/member/settings/','renderMember','settings']]){
|
||||
const f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'info',title:'完整页面',info:{key:path.includes('deposit')?'deposit':tab},session:{user:null,state:'unavailable'}}});
|
||||
f.location.pathname=path;await f.app.route();
|
||||
assert.equal(f.calls.length,1,path);assert.equal(f.calls[0].path,handler,path);
|
||||
if(handler==='renderMember')assert.equal(f.calls[0].args[1],tab);
|
||||
if(path==='/info/deposit/vip/')assert.equal(f.calls[0].args[1],true);
|
||||
if(path==='/info/deposit/')assert.equal(f.calls[0].args[1],false);
|
||||
}
|
||||
const signedOut=fixture(()=>assert.fail('login-required must keep SSR'),{page:{path:'/member/center/',panel:'info',title:'完整页面',info:{key:'center'},session:{user:null,state:'login-required'}}});
|
||||
signedOut.location.pathname='/member/center/';await signedOut.app.route();assert.equal(signedOut.calls.length,0);
|
||||
test('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);
|
||||
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()=>{
|
||||
@@ -169,18 +141,18 @@ test('client configuration preserves the server content metadata and canonical',
|
||||
const path='/topics/7/',environment=seo.resolveSeoEnvironment({config:{foreverUrlAddr:'https://canonical.drama.test'}}),content={title:'服务端专题',description:'服务端简介',totalCount:72,pageSize:24};
|
||||
const f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'channel',title:'服务端专题'},seo:{environment,content,path}});f.location.pathname=path;await f.app.route();
|
||||
await f.app.applyConfig({config:{foreverUrlAddr:'https://other.drama.test'}});
|
||||
assert.equal(f.document.title,'服务端专题 - 禁果短剧');assert.equal(f.document.value('link[rel="canonical"]'),'https://canonical.drama.test/topics/7/');
|
||||
assert.equal(f.document.title,'服务端专题 - 黄果短剧');assert.equal(f.document.value('link[rel="canonical"]'),'https://canonical.drama.test/topics/7/');
|
||||
});
|
||||
|
||||
test('shared server configuration initializes downloads and contacts without refetching page configuration or footer',async()=>{
|
||||
const shared={config:{config:{officialAppAddr:'https://app.test/download'}},footer:{footerInfo:{contactList:[{name:'服务时间',value:'每天'}]}},trackerConfigured:false};
|
||||
const 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',async()=>{
|
||||
const f=fixture();f.location.pathname='/shorts/';f.app.hidePage(true);assert.equal(f.app.signal.aborted,false);
|
||||
f.app.restorePage();assert.equal(f.reloads.length,0);assert.deepEqual(f.calls.map(item=>item.path),['restore-session']);
|
||||
const privatePage=fixture();privatePage.location.pathname='/info/deposit/';privatePage.app.hidePage(true);assert.equal(privatePage.app.signal.aborted,true);
|
||||
privatePage.app.restorePage();assert.equal(privatePage.reloads.length,1);
|
||||
const removed=fixture();removed.location.pathname='/info/about-us/';removed.app.hidePage(true);assert.equal(removed.app.signal.aborted,false);
|
||||
removed.app.restorePage();assert.equal(removed.reloads.length,0);
|
||||
});
|
||||
|
||||
+27
-26
@@ -1,3 +1,4 @@
|
||||
import {linkShared} from './helpers/shared-modules.mjs';
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -7,16 +8,16 @@ import ts from 'typescript';
|
||||
async function moduleUrl(path,transform=code=>code){
|
||||
const source=await readFile(new URL(path,import.meta.url),'utf8');
|
||||
const {outputText}=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||||
return 'data:text/javascript;base64,'+Buffer.from(transform(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+new URL(path,import.meta.url).pathname).toString('base64');
|
||||
return 'data:text/javascript;base64,'+Buffer.from(linkShared(transform(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+new URL(path,import.meta.url).pathname).toString('base64');
|
||||
}
|
||||
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
|
||||
const {ads,promotionRow,renderHome,activeBanners,hero,homeRecommendations,heroFeed,card,coverCornerBadge,topicCard,imageAttributes,blockSection}=await import(renderUrl);
|
||||
const historyUrl=await moduleUrl('../src/lib/client/header-history.ts',code=>code.replace("'../api/protocol'",JSON.stringify(protocolUrl)).replace("'./render'",JSON.stringify(renderUrl)));
|
||||
const {headerHistoryMarkup}=await import(historyUrl);
|
||||
const drama={id:23456,title:'正常剧集 <script>alert(1)</script>',cover:'https://example.com/poster.webp',tagNames:['都市']};
|
||||
const advert={id:'ad-one',title:'有效广告',images:'https://example.com/ad.webp',link:'https://example.com/landing'};
|
||||
|
||||
const itemCount=html=>(html.match(/class="(?:hg-card|curated-card drama-data-card|ranked-card drama-data-card|topic-recommendation-card)"/g)||[]).length;
|
||||
|
||||
test('Swagger homepage modules render actual titles, drama cards and theme destinations',()=>{
|
||||
const html=renderHome({modules:[{id:31,title:'新接口短剧栏目',slug:'editors',moduleType:1,list:[drama]},{id:32,title:'新接口题材',moduleType:2,topicList:[{id:7,slug:'urban',name:'都市题材',list:[drama]}]}]});
|
||||
for(const text of ['新接口短剧栏目','新接口题材','href="/config/editors/"','href="/theme/urban/"','href="/detail/23456/"'])assert.ok(html.includes(text),text);
|
||||
@@ -25,9 +26,17 @@ test('Swagger homepage modules render actual titles, drama cards and theme desti
|
||||
assert.ok(!emptyModules.includes('旧缓存不应复活'));
|
||||
});
|
||||
|
||||
test('topic cards include escaped metadata for content exposure and click tracking',()=>{
|
||||
test('topic cards escape metadata and carry no analytics 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('都市 <甜宠>'));assert.ok(!html.includes('data-track'));assert.ok(!html.includes('data-item-'));
|
||||
});
|
||||
|
||||
test('drama cards use the 9:16 gallery layout with episode counter, square badge and views/theme meta',()=>{
|
||||
const series=card({id:5,title:'连载剧',totalEpisode:12,updateStatus:1,isFeatured:true,heatValue:12345,themeNames:['都市'],cover:'https://example.com/c.webp'});
|
||||
for(const text of ['class="hg-card"','class="hg-card-cover"','>1/12+<','class="hg-card-badge"','cover-corner-badge--hot','class="hg-card-rule"','class="hg-card-views"','1.2万','class="hg-card-theme">都市<'])assert.ok(series.includes(text),text);
|
||||
assert.ok(!series.includes('drama-description'));assert.ok(!series.includes('data-track'));
|
||||
assert.ok(card({id:6,title:'完结',totalEpisode:30,updateStatus:2}).includes('>1/30<'));
|
||||
assert.ok(card({id:7,title:'单集',totalEpisode:1}).includes('>单集<'));
|
||||
});
|
||||
|
||||
test('API images use data sources and local placeholders until decrypted, including video posters',()=>{
|
||||
@@ -53,8 +62,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.ok(html.includes('alt="第二个广告"'));assert.ok(!html.includes('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),'');
|
||||
});
|
||||
@@ -64,7 +73,7 @@ test('ads ignores malformed entries and keeps valid ads in order with escaped te
|
||||
assert.equal((html.match(/class="promo-app"/g)||[]).length,2);
|
||||
assert.ok(html.indexOf('有效广告')<html.indexOf('第二条'));
|
||||
assert.ok(html.includes('href="https://example.com/landing"'));
|
||||
assert.ok(html.includes('href="/apps/"'));assert.ok(html.includes('第二条 <script>'));
|
||||
assert.ok(!html.includes('/apps/'));assert.ok(html.includes('<a class="promo-app"><img'));assert.ok(html.includes('第二条 <script>'));
|
||||
assert.ok(!html.includes('javascript:'));assert.ok(!html.includes('<script>'));
|
||||
});
|
||||
|
||||
@@ -88,7 +97,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.split('id="home-blocks"')[1].match(/class="drama-episodes"/g)||[]).length,1);
|
||||
assert.equal((html.split('id="home-blocks"')[1].match(/class="hg-card-counter"/g)||[]).length,3);
|
||||
assert.ok(html.indexOf('必看好剧')<html.indexOf('热播榜'));
|
||||
});
|
||||
|
||||
@@ -101,7 +110,7 @@ test('homepage banner ads follow each second topic section and keep real links',
|
||||
assert.ok(blocks.indexOf('栏目二')<blocks.indexOf('横幅 a')&&blocks.indexOf('横幅 a')<blocks.indexOf('横幅 c')&&blocks.indexOf('横幅 c')<blocks.indexOf('栏目三'));
|
||||
assert.ok(blocks.indexOf('栏目四')<blocks.indexOf('横幅 d')&&blocks.indexOf('横幅 d')<blocks.indexOf('横幅 e')&&blocks.indexOf('横幅 e')<blocks.indexOf('栏目五'));
|
||||
assert.ok(!blocks.includes('横幅 f'));assert.ok(!blocks.includes('横幅 b'));assert.ok(!blocks.includes('javascript:'));
|
||||
assert.ok(blocks.includes('data-ad-type="banner"'));assert.ok(blocks.includes('href="https://example.com/a"'));
|
||||
assert.ok(!blocks.includes('data-ad-'));assert.ok(blocks.includes('href="https://example.com/a"'));
|
||||
assert.ok(!renderHome({homeConfigData:[block(1,'栏目一'),block(2,'栏目二')]}).includes('wide-ad-row'));
|
||||
assert.ok(!renderHome({homeConfigData:[block(1,'单独栏目')],advitise:{detailbannerList:[banner('a')]}}).includes('wide-ad-row'));
|
||||
});
|
||||
@@ -140,7 +149,7 @@ test('A–E previews use API limits while retaining backend order and full sourc
|
||||
const topics=list.map(item=>({id:item.id,name:'专题 '+item.id,slug:item.slug}));
|
||||
const block={id:1,name:'任意分类',type:style==='E'?3:1,status:true,style,limit,list,themeList:topics,topicKind:'theme'};
|
||||
const html=blockSection(block);
|
||||
assert.equal((html.match(/data-track-item/g)||[]).length,limit,style);
|
||||
assert.equal(itemCount(html),limit,style);
|
||||
const links=[...html.matchAll(/href="\/(?:detail|theme)\/story-(\d+)\/"/g)].map(match=>Number(match[1]));
|
||||
assert.deepEqual(links,Array.from({length:limit},(_,index)=>index),style);
|
||||
assert.equal(list.length,14);assert.equal(topics.length,14);
|
||||
@@ -166,7 +175,7 @@ test('category limits apply to current and legacy modules, including unknown sty
|
||||
{homeConfigData:[{id:1,name:'分类',style,limit,type:topic?3:1,status:true,list:items,themeList:items}]}];
|
||||
for(const home of sources){
|
||||
const html=renderHome(home).split('id="home-blocks"')[1].split('</section>')[0];
|
||||
assert.equal((html.match(/data-track-item/g)||[]).length,Math.min(limit,items.length),String(style)+' '+limit);
|
||||
assert.equal(itemCount(html),Math.min(limit,items.length),String(style)+' '+limit);
|
||||
if(limit===0)assert.ok(html.includes('暂时没有内容'));
|
||||
}
|
||||
}
|
||||
@@ -177,10 +186,10 @@ test('missing or invalid category limits retain all valid returned items without
|
||||
const list=Array.from({length:14},(_,index)=>({...drama,id:100+index}));
|
||||
for(const limit of [undefined,null,-1,1.5,NaN,Infinity,'3','',false,{},Number.MAX_SAFE_INTEGER+1]){
|
||||
const html=blockSection({id:1,name:'分类',type:1,status:true,style:'B',limit,list:[null,false,...list]});
|
||||
assert.equal((html.match(/data-track-item/g)||[]).length,14,String(limit));
|
||||
assert.equal(itemCount(html),14,String(limit));
|
||||
}
|
||||
const html=blockSection({id:1,name:'分类',type:1,status:true,style:'C',limit:2,list:[null,list[0],false,list[1],list[2]]});
|
||||
assert.equal((html.match(/data-track-item/g)||[]).length,2);
|
||||
assert.equal(itemCount(html),2);
|
||||
assert.ok(html.includes('/detail/100/'));assert.ok(html.includes('/detail/101/'));assert.ok(!html.includes('/detail/102/'));
|
||||
});
|
||||
|
||||
@@ -189,7 +198,7 @@ test('missing, reserved, malformed and incompatible category styles preserve the
|
||||
for(const style of [undefined,'','F','Z','AA',0,{},'E','A" onclick="bad']){
|
||||
const html=blockSection({id:1,name:'热门好剧',type:1,status:true,style,list});
|
||||
assert.ok(!html.includes('data-home-style='));assert.ok(html.includes('class="cards"'));
|
||||
assert.equal((html.match(/class="card drama-data-card"/g)||[]).length,12);
|
||||
assert.equal((html.match(/class="hg-card"/g)||[]).length,12);
|
||||
assert.ok(!html.includes('onclick='));
|
||||
}
|
||||
const topics=list.map(item=>({id:item.id,name:'专题'}));
|
||||
@@ -211,10 +220,11 @@ test('cover corner badges prefer 原创 over 爆剧 over 上新',()=>{
|
||||
assert.ok(card({...drama,isNew:true}).includes('>上新</span>'));
|
||||
});
|
||||
|
||||
test('styled category cards keep escaped metadata, native links, tracking and empty states',()=>{
|
||||
test('styled category cards keep escaped metadata, native links and empty states',()=>{
|
||||
for(const style of ['C','D']){
|
||||
const html=blockSection({id:1,name:'分类',type:1,status:true,style,label:'新剧 <b>',list:[null,{...drama,desc:'简介 <script>',recommendTraceId:'trace-123',totalEpisode:36,isNew:true,tagNames:['都市 <img>']}]});
|
||||
for(const value of ['data-recommend-trace-id="trace-123"','data-src="https://example.com/poster.webp"','loading="lazy"','cover-corner-badge--new','上新','36集','简介 <script>','都市 <img>'])assert.ok(html.includes(value),style+' '+value);
|
||||
assert.ok(!html.includes('data-recommend-trace-id'));
|
||||
for(const value of ['data-src="https://example.com/poster.webp"','loading="lazy"','cover-corner-badge--new','上新','36集','简介 <script>','都市 <img>'])assert.ok(html.includes(value),style+' '+value);
|
||||
assert.ok(!html.includes('<script>'));assert.ok(!html.includes('<b>'));
|
||||
if(style==='C'){assert.ok(html.includes('class="pick">最新上架</span>'));assert.ok(!html.includes('新剧 <b>'));assert.ok(!html.includes('class="pick">新剧'));}
|
||||
}
|
||||
@@ -270,12 +280,3 @@ test('homepage landscape recommendations use enabled API content, deduplicate ID
|
||||
});
|
||||
|
||||
|
||||
test('header history shows only valid unique account records, escapes titles and keeps native detail links',()=>{
|
||||
const records=Array.from({length:12},(_,index)=>({...drama,id:index+1,slug:'recent-'+index,totalEpisode:index+3}));
|
||||
const html=headerHistoryMarkup([null,{id:-1},records[0],records[0],...records.slice(1)]);
|
||||
assert.equal((html.match(/class="jg-history-item"/g)||[]).length,9);
|
||||
assert.ok(html.includes('href="/detail/recent-0/"'));assert.ok(html.includes('全 3 集'));
|
||||
assert.ok(html.includes('正常剧集 <script>'));assert.ok(!html.includes('<script>'));
|
||||
assert.ok(html.includes('data-src="https://example.com/poster.webp"'));
|
||||
assert.ok(headerHistoryMarkup(null).includes('暂无观看历史'));
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {linkShared} from './helpers/shared-modules.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
@@ -6,7 +7,7 @@ import ts from 'typescript';
|
||||
async function moduleUrl(file,transform=code=>code){
|
||||
const source=await readFile(new URL(file,import.meta.url),'utf8');
|
||||
const code=ts.transpileModule(source,{fileName:new URL(file,import.meta.url).pathname,compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText;
|
||||
return 'data:text/javascript;base64,'+Buffer.from(transform(code)).toString('base64');
|
||||
return 'data:text/javascript;base64,'+Buffer.from(linkShared(transform(code))).toString('base64');
|
||||
}
|
||||
const protocol=await moduleUrl('../src/lib/api/protocol.ts'),auth=await moduleUrl('../src/lib/client/auth.ts');
|
||||
const apiUrl=await moduleUrl('../src/lib/client/api.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocol)).replace('"./auth"',JSON.stringify(auth)));
|
||||
@@ -15,7 +16,7 @@ const startupUrl='data:text/javascript,export const calls=[];export function sta
|
||||
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;
|
||||
});
|
||||
@@ -55,16 +56,15 @@ test('API business failures, invalid JSON and network failures remain rejected w
|
||||
});
|
||||
});
|
||||
|
||||
test('home, player, navigation and wallet hydration starts normally without logging server data or refetching',async()=>{
|
||||
test('home, player and info hydration starts the application only, without analytics, logging or refetching',async()=>{
|
||||
await browser(async()=>{
|
||||
globalThis.fetch=()=>assert.fail('hydration must use existing server data');
|
||||
const wallet={user:{userId:77,recharge:90,token:'session-secret'},packages:{coinList:[{id:31,name:'金币套餐',price:10000}],vipList:[]},transactions:{list:[{id:12,amount:30}],totalCount:18}};
|
||||
const home={homeConfigData:[{id:31,moreUrl:'/topics/'}]},navigation=[{name:'短剧',url:'/shorts/'}];
|
||||
for(const page of [{path:'/',panel:'home'},{path:'/detail/7/',player:{title:'短剧'}},{path:'/info/deposit/page/2/',panel:'info',info:{key:'deposit',wallet}}]){
|
||||
for(const page of [{path:'/',panel:'home'},{path:'/detail/7/',player:{title:'短剧'}},{path:'/info/about-us/',panel:'info',info:{key:'about-us'}}]){
|
||||
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');
|
||||
assert.ok(!startup.calls.includes('tracker'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {linkShared} from './helpers/shared-modules.mjs';
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -7,7 +8,7 @@ import ts from 'typescript';
|
||||
async function moduleUrl(path, transform=code=>code){
|
||||
const source=await readFile(new URL(path,import.meta.url),'utf8');
|
||||
const {outputText}=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||||
return 'data:text/javascript;base64,'+Buffer.from(transform(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))).toString('base64');
|
||||
return 'data:text/javascript;base64,'+Buffer.from(linkShared(transform(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))).toString('base64');
|
||||
}
|
||||
const routingUrl=await moduleUrl('../src/lib/routing.ts');
|
||||
const {parseSitePath,routeFromSegments,navigationSection,internalPagePath,getRankPage,RANK_PAGES,samePageAddress}=await import(routingUrl);
|
||||
@@ -32,11 +33,9 @@ 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/','']]){
|
||||
assert.deepEqual(parseSitePath('/info/about-us/'),{group:'info',key:'about-us'});
|
||||
for(const path of ['/info/deposit/vip/','/info/deposit/','/info/feedback/','/info/user-center/','/info/settings/','/member/history/','/member/likes/','/member/likes/page/2/','/member/center/'])assert.equal(parseSitePath(path),null,path);
|
||||
for(const [path,section] of [['/','home'],['/shorts/','shorts'],['/topics/31/','topics'],['/detail/7/','shorts']]){
|
||||
assert.equal(navigationSection(parseSitePath(path)),section,path);
|
||||
}
|
||||
});
|
||||
@@ -86,7 +85,8 @@ 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/info/about-us/',base),'/info/about-us/');
|
||||
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/']){
|
||||
assert.equal(internalPagePath(href,base),null,href);
|
||||
@@ -99,8 +99,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;}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {linkShared} from './helpers/shared-modules.mjs';
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -7,7 +8,7 @@ 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');
|
||||
return 'data:text/javascript;base64,'+Buffer.from(linkShared(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)));
|
||||
@@ -103,10 +104,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 and real detail links without analytics or 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'));
|
||||
assert.ok(html.includes('class="search-pick-cover"'));assert.ok(html.includes('width="160" height="240"'));
|
||||
assert.ok(html.includes('data-src="https://cdn.example/encrypted.webp"'));assert.ok(html.includes('alt="SEO 标题封面"'));
|
||||
assert.ok(html.includes('简介 <script>'));assert.ok(html.includes('cover-corner-badge--new'));assert.ok(html.includes('>上新</span>'));assert.ok(html.includes('12集'));
|
||||
|
||||
+7
-10
@@ -65,7 +65,7 @@ test('configuration failures and missing permanent origins retain the last valid
|
||||
|
||||
test('cold starts never guess a canonical and the browser preserves the server identity',()=>{
|
||||
const cold=seo.resolveSeoEnvironment({},[]);
|
||||
assert.equal(cold.origin,'');assert.equal(cold.site.siteName,'禁果短剧');
|
||||
assert.equal(cold.origin,'');assert.equal(cold.site.siteName,'黄果短剧');
|
||||
const head=seo.buildPageHead('/shorts/','',cold,{totalCount:72});
|
||||
assert.equal(head.canonical,undefined);assert.equal(head.jsonLd,undefined);assert.equal(head.next,undefined);
|
||||
assert.equal(seo.clientSeoEnvironment(environment,{config:{foreverUrlAddr:'https://other.drama.test'},seoInfo:{webSiteName:'其他品牌'}}),environment);
|
||||
@@ -87,13 +87,12 @@ test('canonical is query-free, every pagination page identifies itself, and firs
|
||||
assert.equal(page('/shorts/page/1/',{totalCount:72}).canonical,'https://drama.example.net/shorts/');
|
||||
const overflow=page('/shorts/page/4/',{totalCount:72,pageSize:24});assert.equal(overflow.canonical,undefined);assert.match(overflow.robots,/noindex/);assert.equal(overflow.previous,undefined);
|
||||
for(const totalCount of [0,1,24]){const head=page('/shorts/',{totalCount});assert.equal(head.previous,undefined);assert.equal(head.next,undefined);}
|
||||
const center=page('/member/center/',{totalCount:72});assert.equal(center.previous,undefined);assert.equal(center.next,undefined);
|
||||
for(const totalCount of [-1,NaN,'72']){const head=page('/shorts/',{totalCount});assert.equal(head.next,undefined);}
|
||||
});
|
||||
|
||||
test('private/tools/search/error pages do not inherit public canonical or indexability',()=>{
|
||||
for(const path of ['/search/爱情/','/navigation/','/home-road/','/apps/','/unknown-page/']){const head=page(path);assert.match(head.robots,/noindex,follow/);assert.equal(head.canonical,undefined,path);}
|
||||
for(const path of ['/member/center/','/member/likes/','/member/likes/page/2/','/member/favorites/page/2/','/info/deposit/','/info/feedback/'])assert.equal(page(path).robots,'noindex,nofollow',path);
|
||||
for(const path of ['/member/center/','/member/likes/','/info/deposit/','/info/feedback/']){const head=page(path);assert.equal(head.robots,'noindex,follow',path);assert.equal(head.canonical,undefined,path);assert.equal(seo.pageResponseStatus(path,{}),404,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);
|
||||
@@ -114,9 +113,9 @@ 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 privatePage=page('/unknown-page/');clientSeo.applyPageHead(privatePage,doc);
|
||||
assert.equal(doc.value('link[rel="canonical"]'),undefined);assert.equal(doc.value('meta[property="og:url"]'),undefined);assert.equal(doc.value('link[rel="prev"]'),undefined);
|
||||
assert.equal(doc.value('meta[name="robots"]'),'noindex,nofollow');assert.equal(doc.value('meta[name="googlebot"]'),'noindex,nofollow');
|
||||
assert.equal(doc.value('meta[name="robots"]'),'noindex,follow');assert.equal(doc.value('meta[name="googlebot"]'),'noindex,follow');
|
||||
assert.equal(doc.value('meta[name="twitter:title"]'),privatePage.title);
|
||||
});
|
||||
|
||||
@@ -141,7 +140,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 while preserving successful and error statuses; detail/play pages are not paginated',()=>{
|
||||
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);
|
||||
@@ -153,10 +152,8 @@ test('empty public lists and comment pagination are noindex while preserving suc
|
||||
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);
|
||||
const content={...data,totalCount:35,pageSize:10};
|
||||
assert.equal(seo.pageResponseStatus(path+'page/2/',content),404,'detail and play pages no longer paginate comments');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ async function moduleUrl(file,replace=code=>code){
|
||||
return 'data:text/javascript;base64,'+Buffer.from(replace(code)).toString('base64');
|
||||
}
|
||||
const routing=await moduleUrl('../src/lib/routing.ts'),protocol=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const {navigationEntries,navigationIsActive,mobileNavigationEntries}=await import(await moduleUrl('../src/lib/site-navigation.ts',code=>code.replace("'./api/protocol'",JSON.stringify(protocol)).replace("'./routing'",JSON.stringify(routing))));
|
||||
const {navigationEntries,navigationIsActive,mobileNavigationEntries,withoutRemovedNavigation}=await import(await moduleUrl('../src/lib/site-navigation.ts',code=>code.replace("'./api/protocol'",JSON.stringify(protocol)).replace("'./routing'",JSON.stringify(routing))));
|
||||
const item=(id,name,urlValue,extra={})=>({id,name,urlValue,categoryType:1,...extra});
|
||||
|
||||
test('API order, labels, icons and nested destinations are preserved while footer and removed video entries stay absent',()=>{
|
||||
@@ -20,7 +20,14 @@ test('API order, labels, icons and nested destinations are preserved while foote
|
||||
|
||||
test('known API aliases and empty system entries generate native destinations directly without redirects',()=>{
|
||||
const entries=navigationEntries({categoryList:[item(1,'首页',''),item(2,'排行','/hot'),item(3,'回家的路',''),item(4,'福利应用',''),item(5,'名字由后台决定','/shorts?sort=2'),item(6,'未知空地址','')]});
|
||||
assert.deepEqual(entries.map(entry=>entry.href),['/','/rank/hot/','/home-road/','/apps/','/shorts/?sort=2']);
|
||||
assert.deepEqual(entries.map(entry=>entry.href),['/','/rank/hot/','/home-road/','/shorts/?sort=2']);
|
||||
});
|
||||
|
||||
test('backend navigation entries for the removed welfare app directory are filtered out',()=>{
|
||||
const entries=navigationEntries({categoryList:[item(1,'短剧','/shorts/'),item(2,'应用','/apps/'),item(3,'应用二','/application'),item(4,'福利应用','https://drama.test/apps/'),item(5,'福利应用','/topics/'),item(6,'目录','',{categoryType:2,translationList:[{id:7,name:'应用',urlValue:'/apps/'},{id:8,name:'专题',urlValue:'/topics/'}]})]},'https://drama.test');
|
||||
assert.deepEqual(entries.map(entry=>entry.name),['短剧','目录']);
|
||||
assert.deepEqual(entries[1].children.map(entry=>entry.href),['/topics/']);
|
||||
assert.ok(!JSON.stringify(entries).includes('/apps'));
|
||||
});
|
||||
|
||||
test('links reject executable addresses, credentials and removed routes, and keep external URLs external',()=>{
|
||||
@@ -38,3 +45,10 @@ test('active navigation follows configured destinations, independent pages, pagi
|
||||
assert.equal(navigationIsActive('/shorts/?sort=2','/shorts/page/2/?sort=2&campaign=test'),true);assert.equal(navigationIsActive('/shorts/?sort=2','/shorts/?sort=1'),false);
|
||||
assert.equal(navigationIsActive('https://example.net/shorts/','/shorts/'),false);assert.equal(navigationIsActive('/series/','/series/'),false);
|
||||
});
|
||||
|
||||
test('the serialized backend navigation payload no longer carries welfare-app entries',()=>{
|
||||
const data=withoutRemovedNavigation({categoryList:[{id:1,name:'短剧',urlValue:'/shorts/',categoryType:1},{id:2,name:'福利应用',urlValue:'',categoryType:1},{id:3,name:'应用',urlValue:'https://drama.test/apps/',categoryType:1},{id:4,name:'目录',urlValue:'',categoryType:2,translationList:[{id:5,name:'应用',urlValue:'/application'},{id:6,name:'专题',urlValue:'/topics/'}]}]});
|
||||
assert.deepEqual(data.categoryList.map(item=>item.id),[1,4]);
|
||||
assert.deepEqual(data.categoryList[1].translationList.map(item=>item.id),[6]);
|
||||
assert.deepEqual(withoutRemovedNavigation(null).categoryList,[]);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {linkShared} from './helpers/shared-modules.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
@@ -10,7 +11,7 @@ async function moduleUrl(path,imports={}){
|
||||
const {outputText}=ts.transpileModule(await readFile(new URL('../src/lib/'+path,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||||
let code=outputText;
|
||||
for(const [key,url] of Object.entries({'../routing':routingModuleUrl,...imports}))for(const quote of ["'",'"'])code=code.replace(quote+key+quote,JSON.stringify(url));
|
||||
return 'data:text/javascript;base64,'+Buffer.from(code).toString('base64');
|
||||
return 'data:text/javascript;base64,'+Buffer.from(linkShared(code)).toString('base64');
|
||||
}
|
||||
const protocol=await moduleUrl('api/protocol.ts');
|
||||
const renderUrl=await moduleUrl('client/render.ts',{'../api/protocol':protocol});
|
||||
@@ -26,6 +27,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);
|
||||
}
|
||||
|
||||
@@ -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 analytics, 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.deepEqual(tracks,[]);assert.equal(popup.open,true);
|
||||
const cancelAd=new Event('cancel',{cancelable:true});popup.dispatchEvent(cancelAd);await flush();
|
||||
assert.equal(cancelAd.defaultPrevented,true);assert.equal(media.href,'https://example.com/second');assert.equal(popup.open,true);
|
||||
close.click();assert.equal(popup.open,false);assert.equal(store.getItem('dialogAdDismissed'),'2|first|second');
|
||||
assert.deepEqual(tracks,['show','click','show']);
|
||||
assert.equal(grid.open,true);assert.equal(promotions.children.length,1);assert.equal(media.dataset.adPlacement,'startup-ad');
|
||||
assert.deepEqual(tracks,[],'startup ads emit no analytics events');
|
||||
assert.equal(grid.open,true);assert.equal(promotions.children.length,1);assert.equal(media.dataset.adPlacement,undefined);
|
||||
const link=promotions.children[0];assert.equal(link.href,'https://example.com/app');
|
||||
assert.equal(link.rel,'sponsored nofollow noopener noreferrer');assert.equal(link.dataset.adType,'icon');assert.equal(link.dataset.trackAd,'external');assert.equal(link.dataset.adPlacement,'startup-grid');
|
||||
assert.equal(link.rel,'sponsored nofollow noopener noreferrer');assert.equal(link.dataset.adType,undefined);assert.equal(link.dataset.trackAd,undefined);assert.equal(link.dataset.adPlacement,undefined);
|
||||
assert.equal(link.children[0].alt,'app');assert.equal(link.children[1].textContent,'app');
|
||||
const keyboard=new Event('keydown');keyboard.key='Tab';grid.dispatchEvent(keyboard);assert.equal(grid.classList.contains('startup-keyboard-focus'),true);
|
||||
grid.dispatchEvent(new Event('pointerdown'));assert.equal(grid.classList.contains('startup-keyboard-focus'),false);
|
||||
@@ -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(Object.keys(noLink.dataset),[]);
|
||||
assert.equal(noLink.children[0].alt,'<script>inert text</script>');assert.equal(noLink.children[1].textContent,'<script>inert text</script>');
|
||||
assert.equal(promotions.children[1].href,'https://example.com/last');
|
||||
const inertClick=new Event('click');Object.defineProperty(inertClick,'target',{value:noLink.children[0]});promotions.dispatchEvent(inertClick);assert.equal(grid.open,true);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {linkShared} from './helpers/shared-modules.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
@@ -106,10 +107,10 @@ 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 info=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||
const info=await import('data:text/javascript;base64,'+Buffer.from(linkShared(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);assert.ok(!faqPage.html.includes('反馈'));
|
||||
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