import {routingModuleUrl} from './helpers/routing.mjs'; import {test} from 'node:test'; import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; import ts from 'typescript'; import {setMaxListeners} from 'node:events'; import vm from 'node:vm'; import {seoUrl} from './helpers/seo.mjs'; async function moduleUrl(path,transform=code=>code){ const source=await readFile(new URL(path,import.meta.url),'utf8'); const {outputText}=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}); return 'data:text/javascript;base64,'+Buffer.from(transform(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+new URL(path,import.meta.url).pathname).toString('base64'); } const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts'); const playbackModuleUrl=await moduleUrl('../src/lib/client/playback.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`)); const {playbackUrl,chapterAccess,chapterListGate,chapterUnlockCost,initialPlayMode,preferNativeHls,isCodecFailure,MediaPlayback}=await import(playbackModuleUrl); const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`)); const appDownloadUrl=await moduleUrl('../src/lib/app-download.ts',code=>code.replace(/from ['"]\.\/api\/protocol['"]/,`from '${protocolUrl}'`)); const downloadUrl=await moduleUrl('../src/lib/client/download.ts',code=>code.replace(/from ['"]\.\.\/app-download['"]/,`from '${appDownloadUrl}'`)); const detailUrl=await moduleUrl('../src/lib/client/detail.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`).replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`)); const unlockUrl=await moduleUrl('../src/lib/client/unlock.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`).replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`)); const feedbackUrl=await moduleUrl('../src/lib/client/request-feedback.ts'); const markupUrl=await moduleUrl('../src/lib/client/player-markup.ts',code=>code .replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`) .replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`) .replace(/from ['"]\.\/detail['"]/,`from '${detailUrl}'`) .replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`)); const playerDataUrl=await moduleUrl('../src/lib/player-data.ts',code=>code .replace(/from ['"]\.\/api\/protocol['"]/,`from '${protocolUrl}'`) .replace(/from ['"]\.\/seo['"]/,`from '${seoUrl}'`) .replace(/from ['"]\.\/client\/playback['"]/,`from '${playbackModuleUrl}'`)); const {playerInitialData}=await import(playerDataUrl); const playerUrl=await moduleUrl('../src/lib/client/player.ts',code=>code .replace(/from ['"]\.\/player-markup['"]/,`from '${markupUrl}'`) .replace(/from ['"]\.\.\/seo['"]/,`from '${seoUrl}'`) .replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`) .replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`) .replace(/from ['"]\.\/download['"]/,`from '${downloadUrl}'`) .replace(/from ['"]\.\/detail['"]/,`from '${detailUrl}'`) .replace(/from ['"]\.\/unlock['"]/,`from '${unlockUrl}'`) .replace(/from ['"]\.\/request-feedback['"]/,`from '${feedbackUrl}'`) .replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`)); const {renderPlayer}=await import(playerUrl); const 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); 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('episode engagements update chapter counts and keep the numeric chapter write endpoint',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.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('>9收藏')); f.click(controls.collect.button);await flush(); assert.deepEqual(f.posts.at(-1),{path:'playlet/chapter/collect/set',body:{chapterId:90042,isCollect:true}}); assert.equal(controls.collect.stat.textContent,'10收藏');assert.equal(f.chapter.countCollect,10); }finally{dispose?.();f.dispose();} }); test('comment likes update their local count after success without refetching the comment list',async()=>{ const f=playerFixture(),button=new Element('BUTTON'),label=new Element('SPAN');let dispose,resolve; f.page.nodes.delete('video');button.dataset.commentLike='8';button.dataset.commentCount='9';button.setAttribute('aria-pressed','false');label.textContent='♡ 9';button.nodes.set('[data-request-label]',label); f.app.api.post=(path,body)=>{f.posts.push({path,body});return new Promise(yes=>resolve=yes);}; try{ dispose=await renderPlayer(f.app,23456);await flush();const reads=f.getCalls.filter(call=>call.path==='comment/list').length; f.click(button);f.click(button);assert.equal(f.posts.length,1);assert.equal(label.textContent,'请求中');assert.equal(button.disabled,true); resolve(null);await flush();assert.equal(label.textContent,'♥ 10');assert.equal(button.getAttribute('aria-pressed'),'true');assert.equal(button.disabled,false); f.click(button);assert.equal(f.posts.at(-1).body.isLike,false);resolve({countLike:9});await flush();assert.equal(label.textContent,'♡ 9'); assert.equal(f.getCalls.filter(call=>call.path==='comment/list').length,reads); }finally{dispose?.();f.dispose();} }); test('comment submission displays sending feedback, blocks duplicate submits and retains text when the write fails',async()=>{ const f=playerFixture(),input=new Element('TEXTAREA'),button=new Element('BUTTON'),context=new Element(),error=new Element();let dispose,resolve,reject; f.page.nodes.delete('video');button.textContent='发表评论';input.value='评论内容'; const form=f.page.querySelector('form');form.elements={namedItem:()=>input};form.nodes.set('[type="submit"]',button);f.page.nodes.set('#reply-context',context);f.page.nodes.set('#comment-error',error); const submit=()=>{const event=new Event('submit',{cancelable:true});Object.defineProperty(event,'target',{value:form});form.dispatchEvent(event);}; f.app.api.post=(path,body)=>{f.posts.push({path,body});return new Promise((yes,no)=>{resolve=yes;reject=no;});}; try{ dispose=await renderPlayer(f.app,23456);await flush(); submit();submit();assert.equal(f.posts.length,1);assert.equal(button.textContent,'发送中');assert.equal(button.disabled,true); reject(new Error('发送失败'));await flush();assert.equal(input.value,'评论内容');assert.equal(button.textContent,'发表评论');assert.equal(error.textContent,'发送失败');assert.equal(button.disabled,false); submit();resolve({});await flush();assert.equal(input.value,'');assert.equal(button.textContent,'发表评论');assert.equal(button.disabled,false);assert.equal(error.textContent,''); }finally{dispose?.();f.dispose();} }); test('comment pagination uses page links and URL-provided page and sorting',async()=>{ const f=playerFixture({allowed:true,commentData:{list:[],totalCount:95}});let dispose; const href=(page,sort)=>`/play/23456/90042/${page>1?`page/${page}/`:''}${sort===1?'?commentSort=1':''}`; try{ dispose=await renderPlayer(f.app,23456,90042,false,{page:3,commentSort:1,pageHref:href});await flush(); const request=f.getCalls.find(call=>call.path==='comment/list'); assert.equal(request.query.pageNum,3);assert.equal(request.query.sortOrder,1);assert.equal(request.query.pageSize,10); const html=f.page.querySelector('.play-comment-list').innerHTML; assert.ok(html.includes('href="/play/23456/90042/page/4/?commentSort=1"')); assert.ok(!html.includes('data-comment-page'));assert.ok(!html.includes('data-page-number')); assert.ok(f.page.innerHTML.includes('data-comment-sort="1" aria-pressed="true"')); const sort=new Element('BUTTON');sort.dataset.commentSort='2';f.click(sort);await flush(); assert.equal(f.navigations.at(-1),'/play/23456/90042/'); assert.equal(f.getCalls.filter(call=>call.path==='comment/list').length,1); }finally{dispose?.();f.dispose();} }); test('slug playback links survive client episode rendering, next controls, comments and confirmed unlocking with numeric write IDs',async()=>{ const f=playerFixture({allowed:true,detail:{slug:'city-story',tagIds:[9],tagSlugs:['urban'],tagNames:['都市']},commentData:{list:[],totalCount:25}});let dispose; try{ dispose=await renderPlayer(f.app,23456,90042);await flush(); assert.ok(f.page.innerHTML.includes('href="/detail/city-story/"')); assert.ok(f.page.innerHTML.includes('href="/tag/urban/"')); assert.ok(f.page.querySelector('.play-episode-grid').innerHTML.includes('href="/play/city-story/90042/"')); assert.ok(f.page.querySelector('.play-comment-list').innerHTML.includes('href="/play/city-story/90042/page/2/"')); const next=new Element('BUTTON');next.dataset.playAction='next';f.click(next);await flush(); assert.equal(f.navigations.at(-1),'/play/city-story/91200/'); }finally{dispose?.();f.dispose();} const paid=playerFixture({detail:{slug:'city-story'},chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId)});let stop; paid.page.nodes.delete('video'); try{ stop=await renderPlayer(paid.app,23456);paid.click(lockedButton(90042));await flush(); paid.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush(); assert.deepEqual(paid.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}}); assert.equal(paid.navigations.at(-1),'/play/city-story/90042/'); }finally{stop?.();paid.dispose();} }); test('locked chapters offer only the free preview; choosing main opens confirmation and never auto-unlocks or reports preview views',async()=>{ const f=playerFixture();let dispose; try{ dispose=await renderPlayer(f.app,23456,90042);await flush(); assert.equal(f.box.dataset.playMode,'preview');assert.equal(f.video.src,'https://cdn.example/preview.mp4');assert.equal(f.video.plays,0); await f.controlActions.toggle();assert.equal(f.video.plays,1);assert.equal(f.posts.length,0); f.mainButton.dispatchEvent(new Event('click'));await flush(); assert.equal(f.video.src,'');assert.equal(f.box.dataset.playMode,'main'); await f.controlActions.toggle();assert.equal(f.lock.open,true);assert.equal(f.posts.length,0); assert.equal(f.lock.querySelector('.unlock-cost').textContent,'12 金币'); }finally{dispose?.();f.dispose();} }); test('allowed chapters play main despite stale lock flags; only main playback reports, and previous/next use actual IDs',async()=>{ const f=playerFixture({allowed:true});let dispose; try{ dispose=await renderPlayer(f.app,23456,90042);await flush(); assert.equal(f.video.src,'https://cdn.example/main.mp4');await f.controlActions.toggle();await flush(); assert.deepEqual(f.posts,[{path:'playlet/chapter/watch/report',body:{playletId:23456,chapterId:90042}}]); f.video.dispatchEvent(new Event('playing'));await flush();assert.equal(f.posts.length,1); f.previewButton.dispatchEvent(new Event('click'));await flush();assert.equal(f.video.src,'https://cdn.example/preview.mp4');assert.equal(f.posts.length,1); const previous=new Element('BUTTON');previous.dataset.playAction='previous';f.click(previous); const next=new Element('BUTTON');next.dataset.playAction='next';f.click(next);await flush(); assert.deepEqual(f.navigations,['/play/23456/90001/','/play/23456/91200/']); dispose();assert.equal(f.video.src,'');assert.equal(f.video.paused,true); }finally{dispose?.();f.dispose();} }); test('unlocking requires an explicit confirmation, prevents double submission, then refreshes account and chapter data',async()=>{ let finish;const f=playerFixture({preview:false,unlock:()=>new Promise(resolve=>finish=resolve)});let dispose; try{ dispose=await renderPlayer(f.app,23456,90042);await f.controlActions.toggle();assert.equal(f.lock.open,true);assert.equal(f.posts.length,0); const pay=f.lock.querySelector('.unlock-pay');pay.dispatchEvent(new Event('click'));pay.dispatchEvent(new Event('click')); f.lock.close();await f.controlActions.toggle();pay.dispatchEvent(new Event('click')); assert.equal(f.posts.length,1);assert.deepEqual(f.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}}); finish({canWatch:true});await flush();assert.equal(f.lock.open,false);assert.equal(f.refreshes,1);assert.equal(f.routes,1); }finally{dispose?.();f.dispose();} }); test('actual player controls delegate play and retry to the media engine and allow unlock clicks to reach the page',async()=>{ const source=await readFile(new URL('../public/scripts/site-runtime.js',import.meta.url),'utf8'); const start=source.indexOf('function installPlayerControls('),end=source.indexOf('/* ---- sticky-search-header-script ---- */',start); const install=vm.runInNewContext(source.slice(start,end)+'\ninstallPlayerControls;', {document:{},emptyStateMarkup:()=>'
重试
'}); const screen=new Element(),video=new Video(),bar=new Element(),overlay=new Element(),status=new Element(),center=new Element('BUTTON'); video.volume=1;video.currentTime=0;video.duration=0; screen.nodes.set('video',video);screen.nodes.set('.play-screen-controls',bar);screen.nodes.set('.play-screen-overlay',overlay);screen.nodes.set('.play-source-status',status);overlay.nodes.set('button',center); for(const selector of ['[data-play-action="next"]','.player-seek','.player-volume input','[data-media="toggle"]','[data-media="mute"]','[data-media="fullscreen"]','.player-time'])bar.nodes.set(selector,new Element()); let plays=0,retries=0;install(screen,2,{toggle:async()=>{plays++;},retry:async()=>{retries++;}}); const click=(button)=>{const event=new Event('click');Object.defineProperty(event,'target',{value:button});let stopped=false;event.stopPropagation=()=>{stopped=true;};screen.dispatchEvent(event);return stopped;}; assert.equal(click(center),true);await flush();assert.equal(plays,1);assert.equal(video.plays,0); const retry=new Element('BUTTON');retry.dataset.media='retry';click(retry);await flush();assert.equal(retries,1);assert.equal(video.loads,0); const unlock=new Element('BUTTON');unlock.setAttribute('data-show-unlock','');assert.equal(click(unlock),false); screen.dispatchEvent(new Event('playback:error'));assert.equal(center.hidden,true);assert.ok(overlay.innerHTML.includes('重试')); }); test('detail overview keeps original action groups, actual chapter identifiers and styled comment replies',async()=>{ const f=playerFixture({detail:{description:'接口简介',isNew:true,cover:'https://cdn.example/poster.webp'},commentData:{list:[{id:1,userName:'用户