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

535 lines
40 KiB
JavaScript

import {linkShared} from './helpers/shared-modules.mjs';
import {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import ts from 'typescript';
import {setMaxListeners} from 'node:events';
import vm from 'node:vm';
import {seoUrl} from './helpers/seo.mjs';
async function moduleUrl(path,transform=code=>code){
const source=await readFile(new URL(path,import.meta.url),'utf8');
const {outputText}=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
return 'data:text/javascript;base64,'+Buffer.from(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}'`));
const {playbackUrl,chapterAccess,chapterListGate,chapterUnlockCost,initialPlayMode,preferNativeHls,isCodecFailure,MediaPlayback}=await import(playbackModuleUrl);
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
const appDownloadUrl=await moduleUrl('../src/lib/app-download.ts',code=>code.replace(/from ['"]\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
const downloadUrl=await moduleUrl('../src/lib/client/download.ts',code=>code.replace(/from ['"]\.\.\/app-download['"]/,`from '${appDownloadUrl}'`));
const detailUrl=await moduleUrl('../src/lib/client/detail.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`).replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`));
const feedbackUrl=await moduleUrl('../src/lib/client/request-feedback.ts');
const markupUrl=await moduleUrl('../src/lib/client/player-markup.ts',code=>code
.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`)
.replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`)
.replace(/from ['"]\.\/detail['"]/,`from '${detailUrl}'`)
.replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`));
const playerDataUrl=await moduleUrl('../src/lib/player-data.ts',code=>code
.replace(/from ['"]\.\/api\/protocol['"]/,`from '${protocolUrl}'`)
.replace(/from ['"]\.\/seo['"]/,`from '${seoUrl}'`)
.replace(/from ['"]\.\/client\/playback['"]/,`from '${playbackModuleUrl}'`));
const {playerInitialData}=await import(playerDataUrl);
const playerUrl=await moduleUrl('../src/lib/client/player.ts',code=>code
.replace(/from ['"]\.\/player-markup['"]/,`from '${markupUrl}'`)
.replace(/from ['"]\.\.\/player-data['"]/,`from '${playerDataUrl}'`)
.replace(/from ['"]\.\.\/seo['"]/,`from '${seoUrl}'`)
.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`)
.replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`)
.replace(/from ['"]\.\/download['"]/,`from '${downloadUrl}'`)
.replace(/from ['"]\.\/detail['"]/,`from '${detailUrl}'`)
.replace(/from ['"]\.\/request-feedback['"]/,`from '${feedbackUrl}'`)
.replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`));
const {renderPlayer}=await import(playerUrl);
const {playerMarkup}=await import(markupUrl);
const nav=(userAgent,maxTouchPoints=0)=>({userAgent,maxTouchPoints});
const chrome=nav('Mozilla/5.0 Chrome/130 Safari/537.36');
const safari=nav('Mozilla/5.0 Macintosh Version/18 Safari/605.1.15');
const flush=()=>new Promise(resolve=>setImmediate(resolve));
class Element extends EventTarget {
tagName;dataset={};attrs=new Map();nodes=new Map();lists=new Map();children=[];hidden=false;disabled=false;textContent='';innerHTML='';
classList={toggle(){}};
style={setProperty(){}};
constructor(tagName='DIV'){super();this.tagName=tagName;}
setAttribute(key,value){this.attrs.set(key,String(value));}
getAttribute(key){return this.attrs.get(key)??null;}
hasAttribute(key){return this.attrs.has(key);}
removeAttribute(key){this.attrs.delete(key);}
querySelector(selector){return this.nodes.get(selector)??null;}
querySelectorAll(selector){return this.lists.get(selector)||[];}
matches(){return false;}
append(child){this.children.push(child);}
closest(){return this;}
insertAdjacentHTML(_position,html){this.innerHTML+=html;}
}
class Video extends Element {
paused=true;controls=false;native=false;plays=0;loads=0;sourceWrites=[];rejectPlay=false;error=null;
constructor(native=false){super('VIDEO');this.native=native;}
canPlayType(){return this.native?'maybe':'';}
pause(){this.paused=true;}
load(){this.loads++;this.error=null;}
async play(){this.plays++;if(this.rejectPlay)throw new Error('NotAllowed');this.paused=false;this.dispatchEvent(new Event('playing'));}
set src(value){this.sourceWrites.push(value);this.setAttribute('src',value);}
get src(){return this.getAttribute('src')||'';}
}
function hlsClass(supported=true){
return class {
static instances=[];static Events={MANIFEST_PARSED:'manifest',ERROR:'error'};static ErrorTypes={NETWORK_ERROR:'network',MEDIA_ERROR:'media'};
static DefaultConfig={manifestLoadPolicy:{default:{}},fragLoadPolicy:{default:{}}};
static isSupported(){return supported;}
handlers=new Map();destroyed=0;network=0;media=0;stopped=0;
constructor(config){this.config=config;this.constructor.instances.push(this);}
on(event,handler){this.handlers.set(event,handler);}
emit(event,data){this.handlers.get(event)?.(event,data);}
loadSource(source){this.source=source;}
attachMedia(video){this.video=video;}
startLoad(){this.network++;}
recoverMediaError(){this.media++;}
stopLoad(){this.stopped++;}
destroy(){this.destroyed++;}
};
}
function fixture({native=false,navigator=chrome,Hls=hlsClass(),loadHls=async()=>Hls}={}){
const video=new Video(native),controller=new AbortController(),messages=[];let errors=0;
const media=new MediaPlayback(video,{signal:controller.signal,navigator,loadHls,status:value=>messages.push(value),error:()=>errors++});
return {video,controller,Hls,media,messages,get errors(){return errors;}};
}
test('playback addresses preserve signed queries, use the relative proxy once, and reject unsafe paths',()=>{
for(const path of ['folder/file.m3u8?token=a%2Fb+1&x=2','/folder/file.m3u8?token=a%2Fb+1&x=2','/h5/m3u8/folder/file.m3u8?token=a%2Fb+1&x=2','h5/m3u8/folder/file.m3u8?token=a%2Fb+1&x=2'])assert.equal(playbackUrl(path),'/h5/m3u8/folder/file.m3u8?token=a%2Fb+1&x=2');
assert.equal(playbackUrl(' https://cdn.example/a.m3u8?token=abc#x '),'https://cdn.example/a.m3u8?token=abc#x');
for(const value of [null,{},'', 'javascript:alert(1)','blob:abc','//external/stream','../secret','a/%2e%2e/b','a\\b','https://user:password@example.com/a'])assert.equal(playbackUrl(value),'');
});
test('chapter access follows reference precedence, supports legacy free URLs, and never supplies gated main sources',()=>{
const url='https://cdn.example/main.mp4';
assert.equal(chapterAccess({canWatch:true,needUnlock:true,videoUrl:url}).allowed,true);
assert.equal(chapterAccess({canWatch:false,payType:0,videoUrl:url}).allowed,true);
for(const chapter of [{needUnlock:true},{payType:1},{canWatch:false,payType:1}]){
const access=chapterAccess({...chapter,videoUrl:url});assert.equal(access.allowed,false);assert.equal(access.gate,'coins');assert.equal(access.source,'');assert.equal(initialPlayMode(access,url),'preview');
}
assert.equal(chapterAccess({needVip:true,videoUrl:url}).gate,'vip');
assert.equal(chapterAccess({payType:2,videoUrl:url}).gate,'vip');
assert.equal(chapterAccess(null).allowed,false);
assert.equal(chapterUnlockCost({costCoins:12,singleCoins:30}),12);assert.equal(chapterUnlockCost({costCoins:-1,singleCoins:30}),30);
assert.equal(initialPlayMode(chapterAccess({canWatch:true,videoUrl:url}),url),'main');
assert.equal(initialPlayMode(chapterAccess({canWatch:true}),url),'preview');
});
test('native HLS is preferred on Apple browsers while Chromium and shell browsers try MSE first',()=>{
const video=new Video(true);
for(const browser of [safari,nav('Mozilla iPhone Mobile Safari'),nav('Mozilla Macintosh Safari',5)])assert.equal(preferNativeHls(video,browser),true);
for(const browser of [chrome,nav('Android Chrome Safari'),nav('iPhone Quark Safari'),nav('iPhone MicroMessenger Safari')])assert.equal(preferNativeHls(video,browser),false);
assert.equal(preferNativeHls(new Video(false),safari),false);
});
test('Chromium uses hls.js even with a native-HLS claim, with reference buffers and modern load policies',async()=>{
const f=fixture({native:true});
try{
await f.media.setSource('/h5/m3u8/stream?token=1');const hls=f.Hls.instances[0];
assert.equal(hls.source,'/h5/m3u8/stream?token=1');assert.equal(hls.video,f.video);assert.deepEqual(f.video.sourceWrites,[]);
assert.equal(hls.config.maxBufferLength,30);assert.equal(hls.config.maxMaxBufferLength,60);
assert.equal(hls.config.manifestLoadPolicy.default.maxLoadTimeMs,10000);assert.equal(hls.config.fragLoadPolicy.default.maxLoadTimeMs,20000);
assert.equal(f.video.getAttribute('x5-video-player-type'),'h5');assert.equal(f.video.playsInline,true);
assert.equal(f.video.plays,0);await f.media.toggle();assert.equal(f.video.paused,false);await f.media.toggle();assert.equal(f.video.paused,true);
}finally{f.media.dispose();}
});
test('Safari and ordinary MP4 playback avoid loading hls.js',async()=>{
let imports=0;const loadHls=async()=>{imports++;throw Error('must not import');};
for(const [navigator,url] of [[safari,'https://cdn.example/main.m3u8'],[chrome,'https://cdn.example/main.mp4']]){
const f=fixture({native:true,navigator,loadHls});
try{await f.media.setSource(url);assert.equal(f.video.src,url);assert.equal(f.errors,0);}finally{f.media.dispose();}
}
assert.equal(imports,0);
});
test('shell browsers without MSE fall back to native HLS; unsupported and failed library loads show retry states',async()=>{
for(const native of [true,false]){
const f=fixture({native,navigator:nav('iPhone Quark Safari'),Hls:hlsClass(false)});
try{await f.media.setSource('/h5/m3u8/main.m3u8');assert.equal(!!f.video.src,native);assert.equal(f.errors,native?0:1);}finally{f.media.dispose();}
const failed=fixture({native,loadHls:async()=>{throw Error('offline');}});
try{await failed.media.setSource('/h5/m3u8/main.m3u8');assert.equal(!!failed.video.src,native);assert.equal(failed.errors,native?0:1);}finally{failed.media.dispose();}
}
});
test('fatal HLS network and media errors recover at most three times before presenting retry',async()=>{
const f=fixture();
try{
await f.media.setSource('/h5/m3u8/main.m3u8');const hls=f.Hls.instances[0];
hls.emit('error',{fatal:false,type:'network'});assert.equal(hls.network,0);
for(const type of ['network','media','network','network'])hls.emit('error',{fatal:true,type});
assert.equal(hls.network,2);assert.equal(hls.media,1);assert.equal(hls.stopped,1);assert.equal(f.errors,1);
await f.media.retry();assert.equal(hls.destroyed,1);assert.equal(f.Hls.instances.length,2);assert.equal(f.video.plays,1);
}finally{f.media.dispose();}
});
test('source switches destroy the old HLS instance and ignore its late events',async()=>{
const f=fixture();
try{
await f.media.setSource('/h5/m3u8/old.m3u8');const old=f.Hls.instances[0];
await f.media.setSource('https://cdn.example/new.mp4');assert.equal(old.destroyed,1);
old.emit('error',{fatal:true,type:'media'});assert.equal(old.media,0);assert.equal(f.errors,0);
assert.equal(f.video.src,'https://cdn.example/new.mp4');
}finally{f.media.dispose();}
});
test('route cancellation during a pending HLS import cannot create a stale player',async()=>{
let finish;const Hls=hlsClass();const f=fixture({Hls,loadHls:()=>new Promise(resolve=>finish=resolve)});
const pending=f.media.setSource('/h5/m3u8/old.m3u8');
f.controller.abort();finish(Hls);await pending;
assert.equal(Hls.instances.length,0);assert.equal(f.video.src,'');await f.media.toggle();assert.equal(f.video.plays,0);
});
test('a newer native source wins when an older HLS import completes late, and rejected play remains retryable',async()=>{
let finish;const Hls=hlsClass();const f=fixture({Hls,loadHls:()=>new Promise(resolve=>finish=resolve)});
try{
const pending=f.media.setSource('/h5/m3u8/old.m3u8');await f.media.setSource('https://cdn.example/new.mp4');finish(Hls);await pending;
assert.equal(Hls.instances.length,0);assert.equal(f.video.src,'https://cdn.example/new.mp4');
f.video.rejectPlay=true;await f.media.toggle();assert.equal(f.video.paused,true);assert.equal(f.messages.at(-1),'请再次点击播放');
f.video.rejectPlay=false;await f.media.toggle();assert.equal(f.video.paused,false);
}finally{f.media.dispose();}
});
test('codec incompatibility is distinguished from network, quota, and decryption failures',()=>{
for(const details of ['manifestIncompatibleCodecsError','bufferAddCodecError','bufferIncompatibleCodecsError'])assert.equal(isCodecFailure({details}),true);
for(const reason of ['Unsupported HEVC in M2TS found','H.265 codec not supported','incompatible AVC codec'])assert.equal(isCodecFailure({details:'fragParsingError',reason}),true);
assert.equal(isCodecFailure({error:new DOMException('unsupported','NotSupportedError')}),true);
for(const details of ['keyLoadError','fragDecryptError','fragLoadError'])assert.equal(isCodecFailure({details,error:Error('network unavailable')}),false);
assert.equal(isCodecFailure({details:'bufferAppendError',error:new DOMException('full','QuotaExceededError')}),false);
});
test('HEVC MSE codec errors try native HLS once, retaining signatures and playback position',async()=>{
const f=fixture({native:true});const source='/h5/m3u8/hevc.m3u8?id=1&t=2&ticket=a%2Fb+1';
try{
await f.media.setSource(source);const hls=f.Hls.instances[0];f.video.currentTime=42;
hls.emit('error',{fatal:true,type:'media',details:'bufferIncompatibleCodecsError',mimeType:'video/mp4;codecs="hvc1.1.6.L93.B0"'});
assert.equal(hls.destroyed,1);assert.equal(f.video.src,source);assert.equal(f.errors,0);
f.video.currentTime=0;f.video.dispatchEvent(new Event('loadedmetadata'));assert.equal(f.video.currentTime,42);
hls.emit('error',{fatal:true,type:'media',details:'bufferAddCodecError'});assert.equal(f.errors,0);
f.video.error={code:4};f.video.dispatchEvent(new Event('error'));await flush();
assert.equal(f.errors,1);assert.equal(f.Hls.instances.length,1);assert.match(f.messages.at(-1),/H\.264\/H\.265/);
}finally{f.media.dispose();}
});
test('native decode failures can switch to the full HLS engine without showing a premature generic error',async()=>{
const f=fixture({native:true,navigator:safari});let genericErrors=0;f.video.addEventListener('error',()=>genericErrors++);
try{
await f.media.setSource('https://cdn.example/hevc.m3u8?ticket=signed');f.video.currentTime=12.5;
f.video.error={code:3};f.video.dispatchEvent(new Event('error'));f.video.dispatchEvent(new Event('error'));await flush();
assert.equal(genericErrors,0);assert.equal(f.Hls.instances.length,1);assert.equal(f.video.src,'');assert.equal(f.errors,0);
const hls=f.Hls.instances[0];assert.equal(hls.config.startPosition,12.5);assert.equal(hls.source,'https://cdn.example/hevc.m3u8?ticket=signed');
hls.emit('error',{fatal:true,type:'media',details:'bufferAddCodecError'});
assert.equal(f.errors,1);assert.equal(f.video.sourceWrites.length,1);assert.equal(hls.media,0);
}finally{f.media.dispose();}
});
test('nonfatal codec errors and compatible rendition retries are left to hls.js',async()=>{
const f=fixture({native:true});
try{
await f.media.setSource('/h5/m3u8/multi-codec.m3u8');const hls=f.Hls.instances[0];
hls.emit('error',{fatal:false,type:'media',details:'bufferAddCodecError'});
hls.emit('error',{fatal:true,levelRetry:true,type:'media',details:'bufferAddCodecError'});
assert.equal(f.video.src,'');assert.equal(hls.destroyed,0);assert.equal(f.errors,0);
assert.equal(hls.config.videoPreference,undefined);
}finally{f.media.dispose();}
});
test('unsupported HEVC reports once without repeatedly recovering an incompatible decoder',async()=>{
const f=fixture();
try{
await f.media.setSource('/h5/m3u8/hevc-only.m3u8');const hls=f.Hls.instances[0];
for(let index=0;index<3;index++)hls.emit('error',{fatal:true,type:'media',details:'manifestIncompatibleCodecsError'});
assert.equal(hls.media,0);assert.equal(hls.network,0);assert.equal(f.errors,1);assert.match(f.messages.at(-1),/不支持此视频编码/);
await f.media.toggle();assert.equal(f.video.plays,0);
}finally{f.media.dispose();}
});
test('native MP4 codec failures show the decoding limitation without treating MP4 as an HLS playlist',async()=>{
let imports=0;const f=fixture({loadHls:async()=>{imports++;return hlsClass();}});
try{
await f.media.setSource('https://cdn.example/hevc.mp4');
f.video.error={code:2};f.video.dispatchEvent(new Event('error'));assert.equal(f.errors,1);assert.match(f.messages.at(-1),/视频加载失败/);
await f.media.setSource('https://cdn.example/hevc.mp4');
f.video.error={code:4};f.video.dispatchEvent(new Event('error'));
assert.equal(imports,0);assert.equal(f.errors,2);assert.match(f.messages.at(-1),/H\.264\/H\.265/);
}finally{f.media.dispose();}
});
test('native HLS network failure reports the terminal error once and an aborted load is not counted',async()=>{
const f=fixture({native:true,navigator:safari});
try{
await f.media.setSource('https://cdn.example/main.m3u8');
f.video.error={code:1};f.video.dispatchEvent(new Event('error'));assert.equal(f.errors,0);
f.video.error={code:2};f.video.dispatchEvent(new Event('error'));f.video.dispatchEvent(new Event('error'));
assert.equal(f.errors,1);assert.match(f.messages.at(-1),/视频加载失败/);assert.equal(f.Hls.instances.length,0);
}finally{f.media.dispose();}
});
test('switching routes during a native-to-MSE fallback cannot install an outdated decoder',async()=>{
let finish;const Hls=hlsClass();const f=fixture({native:true,navigator:safari,Hls,loadHls:()=>new Promise(resolve=>finish=resolve)});
await f.media.setSource('/h5/m3u8/old.m3u8');
f.video.error={code:4};f.video.dispatchEvent(new Event('error'));f.controller.abort();finish(Hls);await flush();
assert.equal(Hls.instances.length,0);assert.equal(f.video.src,'');assert.equal(f.errors,0);
});
function playerFixture({allowed=false,preview=true,unlock=async()=>({}),commentData={list:[],totalCount:0},detail={},chapterList,mobile=false,loadChapter}={}){
const original=Object.fromEntries(['document','window','matchMedia'].map(key=>[key,globalThis[key]]));
const signal=new AbortController();setMaxListeners(30,signal.signal);
const page=new Element('MAIN'),box=new Element(),video=new Video(),overlay=new Element(),status=new Element(),lock=new Element('DIALOG');
lock.open=false;lock.showModal=()=>{lock.open=true;};lock.close=()=>{const wasOpen=lock.open;lock.open=false;if(wasOpen)lock.dispatchEvent(new Event('close'));};
const controlActions={};const mainButton=new Element('BUTTON'),previewButton=new Element('BUTTON');
mainButton.dataset.playMode='main';previewButton.dataset.playMode='preview';
const node=(parent,key)=>{if(!parent.nodes.has(key))parent.nodes.set(key,new Element());return parent.nodes.get(key);};
page.nodes.set('video',video);page.nodes.set('.play-screen',box);page.nodes.set('.play-source-status',status);
box.nodes.set('.play-screen-overlay',overlay);box.nodes.set('[data-media="toggle"]',new Element('BUTTON'));
box.nodes.set('[data-play-action="previous"]',new Element('BUTTON'));box.nodes.set('[data-play-action="next"]',new Element('BUTTON'));
for(const key of ['.play-episode-pages','.play-episode-grid','.play-comment-list','#play-comments-title','form'])node(page,key);
page.nodes.set('.play-comment-form',page.nodes.get('form'));
for(const key of ['.unlock-close','.unlock-pay','#episode-lock-balance','.unlock-cost','#episode-lock-error','#episode-lock-description','.unlock-recharge'])node(lock,key);
page.lists.set('button[data-play-mode]',preview?[mainButton,previewButton]:[]);page.lists.set('[data-play-mode]',preview?[box,mainButton,previewButton]:[box]);
const doc=new EventTarget();doc.getElementById=id=>id==='drama-play-page'?page:lock;doc.createElement=tag=>new Element(tag.toUpperCase());
const browser=new EventTarget();browser.navigator=chrome;
browser.installPlayerControls=(_box,_episode,actions)=>{Object.assign(controlActions,actions);};
globalThis.document=doc;globalThis.window=browser;globalThis.matchMedia=()=>({matches:mobile});
const posts=[],navigations=[],notifications=[],getCalls=[];let routes=0,refreshes=0;
const chapters=chapterList||[{id:90001,currentEpisode:1},{id:90042,currentEpisode:2},{id:91200,currentEpisode:3}];
const chapter={id:90042,playletId:23456,currentEpisode:2,canWatch:allowed,needUnlock:!allowed,payType:1,costCoins:12,balance:90,videoUrl:'https://cdn.example/main.mp4'};
const api={user:{recharge:90},get:async(path,query)=>{
getCalls.push({path,query});
if(path==='playlet/detail')return {id:23456,title:'测试剧集',preview:preview?'https://cdn.example/preview.mp4':'',playletChapterInfo:chapters,...detail};
if(path==='playlet/chapter/list')return {list:chapters};
if(path==='playlet/chapter/detail')return loadChapter?loadChapter(query):chapter;
if(path==='comment/list')return commentData;
throw Error(path);
},post:async(path,body)=>{posts.push({path,body});return path==='playlet/chapter/unlock'?unlock():{};},setUser(user){api.user=user;},refreshUser:async()=>{refreshes++;}};
const app={api,signal:signal.signal,config:{},home:{},notify:value=>notifications.push(value),navigate:path=>navigations.push(path),route:async()=>{routes++;}};
return {app,page,video,box,status,lock,posts,navigations,notifications,getCalls,mainButton,previewButton,controlActions,chapter,
get routes(){return routes;},get refreshes(){return refreshes;},
click(button){const event=new Event('click');Object.defineProperty(event,'target',{value:button});page.dispatchEvent(event);},
dispose(){signal.abort();for(const [key,value] of Object.entries(original)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}}};
}
function engagementControls(f,{like=197,collect=2}={}){
const result={};
for(const [kind,value] of [['like',like],['collect',collect]]){
const button=new Element('BUTTON'),label=new Element('SPAN'),stat=new Element('SPAN');
button.dataset.engage=kind;button.setAttribute('aria-pressed','false');label.textContent=kind==='like'?'点赞':'收藏';stat.textContent=value+(kind==='like'?'点赞':'收藏');
button.nodes.set('span',label);f.page.lists.set(`[data-engage="${kind}"]`,[button]);f.page.lists.set(`[data-engage-count="${kind}"]`,[stat]);
result[kind]={button,label,stat};
}
return result;
}
test('likes and collections show pending feedback, reject duplicate clicks and update both counters after empty successes',async()=>{
const f=playerFixture({detail:{countLike:197,countCollect:2}}),controls=engagementControls(f),requests=[];let dispose;
f.page.nodes.delete('video');
f.app.api.post=(path,body)=>{f.posts.push({path,body});return new Promise(resolve=>requests.push(resolve));};
try{
dispose=await renderPlayer(f.app,23456);await flush();const reads=f.getCalls.length;
assert.ok(f.page.innerHTML.includes('data-engage-count="like"'));
f.click(controls.like.button);f.click(controls.like.button);
assert.equal(f.posts.length,1);assert.equal(controls.like.button.disabled,true);assert.equal(controls.like.button.getAttribute('aria-busy'),'true');assert.equal(controls.like.label.textContent,'请求中');
assert.equal(controls.like.stat.textContent,'197点赞','counts change only after the write succeeds');
f.click(controls.collect.button);assert.equal(f.posts.length,2);
requests[0](null);await flush();assert.equal(controls.like.stat.textContent,'198点赞');assert.equal(controls.like.label.textContent,'已点赞');
requests[1]({countCollect:3,isLike:0,countLike:0});await flush();assert.equal(controls.collect.stat.textContent,'3收藏');assert.equal(controls.collect.label.textContent,'已收藏');
f.click(controls.like.button);assert.equal(f.posts.at(-1).body.isLike,false,'a concurrent collection response must not undo the like state');requests[2]({});await flush();
assert.equal(controls.like.stat.textContent,'197点赞');assert.equal(controls.like.label.textContent,'点赞');assert.equal(controls.like.button.getAttribute('aria-pressed'),'false');
assert.equal(controls.like.button.disabled,false);assert.equal(controls.like.button.hasAttribute('aria-busy'),false);
assert.equal(f.getCalls.length,reads,'engagement writes do not wait for a detail refetch');
}finally{dispose?.();f.dispose();}
});
test('failed engagement writes restore controls and preserve counts; late responses after leaving do not change them',async()=>{
const f=playerFixture({detail:{countLike:0,countCollect:0}}),controls=engagementControls(f,{like:0,collect:0});let dispose,resolve,reject;
f.page.nodes.delete('video');
f.app.api.post=()=>new Promise((yes,no)=>{resolve=yes;reject=no;});
try{
dispose=await renderPlayer(f.app,23456);await flush();
f.click(controls.like.button);reject(new Error('请求失败'));await flush();
assert.equal(controls.like.stat.textContent,'0点赞');assert.equal(controls.like.label.textContent,'点赞');assert.equal(controls.like.button.disabled,false);assert.equal(controls.like.button.hasAttribute('aria-busy'),false);assert.equal(f.notifications.at(-1),'请求失败');
f.click(controls.like.button);resolve(null);await flush();assert.equal(controls.like.stat.textContent,'1点赞');
f.click(controls.like.button);f.dispose();resolve(null);await flush();assert.equal(controls.like.stat.textContent,'1点赞');
}finally{dispose?.();f.dispose();}
});
test('play page likes and collections write the drama endpoints',async()=>{
const f=playerFixture({allowed:true,detail:{countCollect:200,countLike:300}}),controls=engagementControls(f,{collect:9,like:7});let dispose;
f.chapter.countCollect=9;f.chapter.countLike=7;f.chapter.isCollect=1;f.page.nodes.delete('video');
f.app.api.post=async(path,body)=>{f.posts.push({path,body});return null;};
try{
dispose=await renderPlayer(f.app,23456,90042);await flush();
assert.ok(f.page.innerHTML.includes('>200收藏</span>'));
assert.ok(f.page.innerHTML.includes('data-engage="collect" aria-pressed="false"'));
f.click(controls.collect.button);await flush();
assert.deepEqual(f.posts.at(-1),{path:'playlet/collect/set',body:{id:23456,isCollect:true}});
assert.equal(controls.collect.stat.textContent,'201收藏');assert.equal(controls.collect.label.textContent,'已收藏');assert.equal(f.chapter.countCollect,9);assert.equal(f.chapter.isCollect,1);
f.chapter.isLike=1;f.app.api.post=async(path,body)=>{f.posts.push({path,body});return {id:23456,isLike:1,countLike:301};};
f.click(controls.like.button);await flush();
assert.deepEqual(f.posts.at(-1),{path:'playlet/like/set',body:{id:23456,isLike:true}},'play page likes the drama, not the chapter');
assert.equal(controls.like.stat.textContent,'301点赞');assert.equal(controls.like.label.textContent,'已点赞');assert.equal(f.chapter.isLike,1);
}finally{dispose?.();f.dispose();}
});
test('play page like follows the drama status and writes playlet/like/set',async()=>{
const f=playerFixture({allowed:true,detail:{isLike:1,countLike:12,isCollect:2,countCollect:4}}),controls=engagementControls(f,{like:12,collect:4});let dispose;
f.chapter.isLike=2;f.chapter.isCollect=1;f.chapter.countCollect=4;f.page.nodes.delete('video');
f.app.api.post=async(path,body)=>{f.posts.push({path,body});return {id:23456,isLike:2,countLike:11};};
try{
dispose=await renderPlayer(f.app,23456,90042);await flush();
assert.ok(f.page.innerHTML.includes('data-engage="like" aria-pressed="true"'));
assert.ok(f.page.innerHTML.includes('>已点赞</span>'));
assert.ok(f.page.innerHTML.includes('data-engage="collect" aria-pressed="false"'));
f.click(controls.like.button);await flush();
assert.deepEqual(f.posts.at(-1),{path:'playlet/like/set',body:{id:23456,isLike:false}});
assert.equal(controls.like.label.textContent,'点赞');assert.equal(controls.like.stat.textContent,'11点赞');assert.equal(controls.like.button.getAttribute('aria-pressed'),'false');
assert.equal(f.chapter.isLike,2);
}finally{dispose?.();f.dispose();}
});
test('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.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);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 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.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');
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();}
});
test('allowed chapters play main despite stale lock flags; only main playback reports, and previous/next use actual IDs',async()=>{
const f=playerFixture({allowed:true});let dispose;
try{
dispose=await renderPlayer(f.app,23456,90042);await flush();
assert.equal(f.video.src,'https://cdn.example/main.mp4');await f.controlActions.toggle();await flush();
assert.deepEqual(f.posts,[{path:'playlet/chapter/watch/report',body:{playletId:23456,chapterId:90042}}]);
f.video.dispatchEvent(new Event('playing'));await flush();assert.equal(f.posts.length,1);
f.previewButton.dispatchEvent(new Event('click'));await flush();assert.equal(f.video.src,'https://cdn.example/preview.mp4');assert.equal(f.posts.length,1);
const previous=new Element('BUTTON');previous.dataset.playAction='previous';f.click(previous);
const next=new Element('BUTTON');next.dataset.playAction='next';f.click(next);await flush();
assert.deepEqual(f.navigations,['/play/23456/90001/','/play/23456/91200/']);
dispose();assert.equal(f.video.src,'');assert.equal(f.video.paused,true);
}finally{dispose?.();f.dispose();}
});
test('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>'});
const screen=new Element(),video=new Video(),bar=new Element(),overlay=new Element(),status=new Element(),center=new Element('BUTTON');
video.volume=1;video.currentTime=0;video.duration=0;
screen.nodes.set('video',video);screen.nodes.set('.play-screen-controls',bar);screen.nodes.set('.play-screen-overlay',overlay);screen.nodes.set('.play-source-status',status);overlay.nodes.set('button',center);
for(const selector of ['[data-play-action="next"]','.player-seek','.player-volume input','[data-media="toggle"]','[data-media="mute"]','[data-media="fullscreen"]','.player-time'])bar.nodes.set(selector,new Element());
let plays=0,retries=0;install(screen,2,{toggle:async()=>{plays++;},retry:async()=>{retries++;}});
const click=(button)=>{const event=new Event('click');Object.defineProperty(event,'target',{value:button});let stopped=false;event.stopPropagation=()=>{stopped=true;};screen.dispatchEvent(event);return stopped;};
assert.equal(click(center),true);await flush();assert.equal(plays,1);assert.equal(video.plays,0);
const retry=new Element('BUTTON');retry.dataset.media='retry';click(retry);await flush();assert.equal(retries,1);assert.equal(video.loads,0);
const unlock=new Element('BUTTON');unlock.setAttribute('data-show-unlock','');assert.equal(click(unlock),false);
screen.dispatchEvent(new Event('playback:error'));assert.equal(center.hidden,true);assert.ok(overlay.innerHTML.includes('重试'));
});
test('detail overview keeps 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();
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.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('推荐 &lt;一&gt;'));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('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 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();}
});
test('episode list gating follows reference permissions independently of video URLs',()=>{
for(const chapter of [{payType:0},{payType:1,isUnlocked:true},{canWatch:true,needUnlock:true,needVip:true,payType:1},{isUnlocked:true,needUnlock:true},{needVip:true,isUnlocked:true}])assert.equal(chapterListGate(chapter),'');
for(const chapter of [{needUnlock:true},{payType:1}])assert.equal(chapterListGate(chapter),'coins');
for(const chapter of [{needVip:true},{payType:2}])assert.equal(chapterListGate(chapter),'vip');
assert.equal(chapterAccess({isUnlocked:true,needUnlock:true,payType:1,videoUrl:'https://cdn.example/owned.mp4'}).allowed,true);
});
const lockedList=[{id:90001,currentEpisode:1,canWatch:true},{id:90042,currentEpisode:2,needUnlock:true,payType:1,singleCoins:30},{id:91200,currentEpisode:3,needUnlock:true,payType:1,singleCoins:30}];
const lockedChapter=(id=90042,extra={})=>({id,playletId:23456,currentEpisode:id===90042?2:3,needUnlock:true,payType:1,costCoins:12,singleCoins:30,balance:90,...extra});
const lockedButton=id=>{const button=new Element('BUTTON');button.dataset.lockedChapter=String(id);return button;};
test('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 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','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,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',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};
try{
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.posts.length,0);
}finally{dispose?.();f.dispose();}
});