import {routingModuleUrl} from './helpers/routing.mjs'; import {test} from 'node:test'; import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; import vm from 'node:vm'; import ts from 'typescript'; import {seo,clientSeo,headDocument} from './helpers/seo.mjs'; const routingFile=new URL('../src/lib/routing.ts',import.meta.url); const routingCode=ts.transpileModule(await readFile(routingFile,'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText; const routing=await import('data:text/javascript;base64,'+Buffer.from(routingCode+'\n//# sourceURL='+routingFile.pathname).toString('base64')); const protocolFile=new URL('../src/lib/api/protocol.ts',import.meta.url); const protocolCode=ts.transpileModule(await readFile(protocolFile,'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText; const protocolUrl='data:text/javascript;base64,'+Buffer.from(protocolCode).toString('base64'); const protocol=await import(protocolUrl); const renderCode=ts.transpileModule(await readFile(new URL('../src/lib/client/render.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText; const renderUrl='data:text/javascript;base64,'+Buffer.from(renderCode.replace("'../routing'",JSON.stringify(routingModuleUrl)).replace('"../api/protocol"',JSON.stringify(protocolUrl))).toString('base64'); const searchCode=ts.transpileModule(await readFile(new URL('../src/lib/client/search.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText; const search=await import('data:text/javascript;base64,'+Buffer.from(searchCode.replace("'../routing'",JSON.stringify(routingModuleUrl)).replace("'../api/protocol'",JSON.stringify(protocolUrl)).replace("'./render'",JSON.stringify(renderUrl))).toString('base64')); const infoCode=ts.transpileModule(await readFile(new URL('../src/lib/info-pages.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText; const infoPages=await import('data:text/javascript;base64,'+Buffer.from(infoCode).toString('base64')); const siteNavigationCode=ts.transpileModule(await readFile(new URL('../src/lib/site-navigation.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText; const applicationFile=new URL('../src/lib/client/application.ts',import.meta.url); const applicationCode=ts.transpileModule(await readFile(applicationFile,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText; const channelCode=ts.transpileModule(await readFile(new URL('../src/lib/client/channel.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText; function fixture(load=async()=>({list:[{id:1}],totalCount:72,name:'接口通用标题'}),options={}){ const nodes=new Map(),calls=[],cardCalls=[],reloads=[]; const location={pathname:'/rank/hot/',search:'',origin:'https://site.test',reload:()=>reloads.push(location.pathname+location.search)}; const sidebar={dataset:{page:'rank'},classList:{toggle(name,active){this[name]=active;}},setAttribute(){},removeAttribute(){}}; const document={...headDocument(),querySelector(selector){ if(selector==='dialog[open]')return null; if(!nodes.has(selector))nodes.set(selector,{innerHTML:'',textContent:'',hidden:false,dataset:{},setAttribute(name,value){this[name]=value;}}); return nodes.get(selector); },querySelectorAll:selector=>selector.startsWith('a[data-page]')?[sidebar]:selector==='[data-promotion-row]'?options.promotionRows||[]:[]}; class ApiClient {async get(path,query,signal){calls.push({path,...query,signal});return load(path,query,signal);}async revalidateSession(){calls.push({path:'restore-session'});}} const exports={},channelExports={},siteNavigationExports={}; const require=specifier=>{ if(specifier==='../site-navigation')return siteNavigationExports; if(specifier==='./routing')return routing; if(specifier==='./api/protocol')return protocol; if(specifier==='./channel')return channelExports; if(specifier==='./api')return {ApiClient}; if(specifier==='../routing')return routing; if(specifier==='../api/protocol')return protocol; if(specifier==='./search')return search; if(specifier==='../info-pages')return infoPages; if(specifier==='../seo')return seo; if(specifier==='./seo')return {applyPageHead:head=>clientSeo.applyPageHead(head,document)}; if(specifier==='./config')return {configureSite:async()=>{}}; if(specifier==='./hero-swipe')return {bindHeroSwipe(){}}; if(specifier==='./promo-marquee')return {bindPromotionMarquee:root=>{calls.push({path:'bindPromotionMarquee',root});return ()=>calls.push({path:'disposePromotionMarquee',root});}}; if(specifier==='./account')return {renderMember:async(...args)=>calls.push({path:'renderMember',args}),renderWallet:async(...args)=>calls.push({path:'renderWallet',args})}; if(specifier==='./feedback')return {renderFeedbackPage:(...args)=>calls.push({path:'renderFeedbackPage',args})}; if(specifier==='./player')return {renderPlayer:async(_app,id,chapterId,isVideo,options)=>{calls.push({path:'renderPlayer',id,chapterId,isVideo,...options});return ()=>{};}}; if(specifier==='./render')return {escape:String,empty:message=>`

${message}

`,loading:()=>'

加载中

',activeBanners:()=>[],topicCard:()=>'专题',renderHome:()=>'
接口首页
',card:(item,video,options)=>{cardCalls.push({item,video,options});return '剧集';},pagination:(_,page)=>`第 ${page} 页`}; return {}; }; vm.runInNewContext(siteNavigationCode,{exports:siteNavigationExports,require,URL,URLSearchParams}); vm.runInNewContext(channelCode,{exports:channelExports,require,URL,URLSearchParams}); vm.runInNewContext(applicationCode,{exports,require,document,location,AbortController,Event,Error,URL,URLSearchParams,clearInterval(){},window:{location,scrollTo(){},dispatchEvent(){}}},{filename:applicationFile.pathname}); return {app:new exports.Application({},true,options.seo,options.page,undefined,options.shared),nodes,calls,cardCalls,location,document,sidebar,reloads,channel:channelExports}; } test('all native pages bind every square ad row and clean up old loops before rebinding',async()=>{ for(const pathname of ['/','/shorts/','/detail/7/','/play/7/8/','/member/favorites/','/info/deposit/']){ const rows=[{name:'first row'},{name:'second row'}],f=fixture(undefined,{promotionRows:rows}); f.location.pathname=pathname;await f.app.route(); assert.deepEqual(f.calls.filter(call=>call.path==='bindPromotionMarquee').map(call=>call.root),rows,pathname); f.app.bindPromotions(); assert.deepEqual(f.calls.filter(call=>call.path==='disposePromotionMarquee').map(call=>call.root),rows,pathname); assert.equal(f.calls.filter(call=>call.path==='bindPromotionMarquee').length,4,pathname); } }); test('about uses business Telegram and the feedback group while retaining configured service hours',()=>{ const page=infoPages.getInfoPage('about-us',{config:{telegram:'https://t.me/business_team',tgGroup:'https://t.me/feedback_group',gmailAddr:'ignored@example.com',customerServiceHost:'https://ignored.example.com'}},[ {name:'商务合作',value:'business@example.com'},{name:'用户反馈',value:'mailto:support@example.com'}, {name:'服务时间',value:'周一至周五 09:00–18:00'},{name:'邮箱',value:'ignored@example.com'}, ]); assert.ok(page.html.includes('商务合作:服务时间:周一至周五 09:00–18:00

')); assert.ok(!page.html.includes('mailto:'));assert.ok(!page.html.includes('@example.com'));assert.ok(!page.html.includes('ignored.example.com')); }); test('Telegram contacts prefer site configuration, fall back to address configuration and hide absent channels',()=>{ const html=infoPages.infoContactSection({config:{telegram:'',tgGroup:''},newAddressInfo:{telegram:'@business_team',tgGroup:'https://t.me/feedback_group'}},null); assert.ok(html.includes('商务合作:
{ const html=infoPages.infoContactSection({},[ {name:'',value:''}, {name:'注入',value:'javascript:alert(1)'}, {name:'数据链接',value:'data:text/html,'}, {name:'正常链接',value:'https://example.com/?a=1&b=2'},null, ]); assert.ok(html.includes('<script>'));assert.ok(html.includes('<img onerror=alert(1)>')); assert.ok(!html.includes('{ const f=fixture(); for(const item of routing.RANK_PAGES){ const route=routing.parseSitePath(item.path+'page/2/'),request=f.channel.channelRequest(route); assert.equal(request.endpoint,'playlet/hot/rank/list');assert.equal(request.query.rankType,item.rankType);assert.equal(request.query.pageNum,2); const view=f.channel.channelView(route,{list:[{id:1}],totalCount:72},item.path+'page/2/'); assert.equal(view.title,item.title);assert.ok(view.html.includes('href="'+item.path+'" aria-current="page"'));assert.equal(f.cardCalls.at(-1).options.rank,25); } }); test('fixed information definitions remain static and contain no executable content',()=>{ for(const page of Object.values(infoPages.INFO_PAGES)){assert.ok(page.html);assert.ok(!page.html.includes('{ for(const page of [{path:'/shorts/page/2/?sort=2',panel:'channel',title:'服务端栏目'},{path:'/search/'+encodeURIComponent('都市 爱情')+'/',panel:'search',title:'搜索结果',search:{keyword:'都市 爱情',total:40,page:1,pageSize:25}}]){ const f=fixture(()=>assert.fail('page data belongs to SSR'),{page}); const address=new URL(page.path,'https://site.test');f.location.pathname=address.pathname;f.location.search=address.search; const selector=page.panel==='channel'?'#channel-content':'#search-results-page';f.document.querySelector(selector).innerHTML='
完整首屏正文
'; await f.app.route();assert.equal(f.nodes.get(selector).innerHTML,'
完整首屏正文
');assert.equal(f.calls.length,0); await f.app.route();assert.deepEqual(f.reloads,[page.path]);assert.equal(f.nodes.get(selector).innerHTML,'
完整首屏正文
'); } }); test('server player data binds once with its comment route and never refetches the initial content',async()=>{ const player={detail:{id:23456,title:'剧集'},chapters:[],chapter:null,comments:{list:[],totalCount:0}}; const path='/play/23456/90001/page/2/',f=fixture(()=>assert.fail('no page fetch'),{page:{path:path+'?commentSort=1',panel:'player',title:'剧集',player}}); f.location.pathname=path;f.location.search='?commentSort=1';await f.app.route(); assert.equal(f.calls.length,1);assert.equal(f.calls[0].path,'renderPlayer');assert.equal(f.calls[0].initial,player);assert.equal(f.calls[0].preserveMarkup,true);assert.equal(f.calls[0].page,2);assert.equal(f.calls[0].commentSort,1); assert.equal(f.calls[0].pageHref(3,1),'/play/23456/90001/page/3/?commentSort=1');await f.app.route();assert.equal(f.calls.length,1);assert.equal(f.reloads.length,1); }); test('private and feedback pages only bind interactions to their prepared snapshots',async()=>{ for(const [path,info,handler] of [['/member/settings/',{key:'settings',member:{user:{userId:7}}},'renderMember'],['/info/deposit/',{key:'deposit',wallet:{user:{userId:7},packages:{coinList:[]},transactions:{list:[]}}},'renderWallet'],['/info/feedback/',{key:'feedback',feedback:true},'renderFeedbackPage']]){ const f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'info',title:'完整页面',info}});f.location.pathname=path;await f.app.route(); assert.equal(f.calls.length,1);assert.equal(f.calls[0].path,handler);assert.equal(f.calls[0].args.at(-1),true); } }); test('cold unavailable member and wallet pages client-render after guest bootstrap',async()=>{ for(const [path,handler,tab] of [['/member/center/','renderMember','center'],['/member/history/','renderMember','history'],['/info/deposit/','renderWallet',undefined],['/info/deposit/vip/','renderWallet',undefined],['/member/settings/','renderMember','settings']]){ const f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'info',title:'完整页面',info:{key:path.includes('deposit')?'deposit':tab},session:{user:null,state:'unavailable'}}}); f.location.pathname=path;await f.app.route(); assert.equal(f.calls.length,1,path);assert.equal(f.calls[0].path,handler,path); if(handler==='renderMember')assert.equal(f.calls[0].args[1],tab); if(path==='/info/deposit/vip/')assert.equal(f.calls[0].args[1],true); if(path==='/info/deposit/')assert.equal(f.calls[0].args[1],false); } const signedOut=fixture(()=>assert.fail('login-required must keep SSR'),{page:{path:'/member/center/',panel:'info',title:'完整页面',info:{key:'center'},session:{user:null,state:'login-required'}}}); signedOut.location.pathname='/member/center/';await signedOut.app.route();assert.equal(signedOut.calls.length,0); }); test('missing or mismatched snapshots leave server error HTML visible without a client retry',async()=>{ const f=fixture(()=>assert.fail('errors must not trigger a second page request'),{page:{path:'/shorts/',panel:'channel',title:'旧页'}}); f.location.pathname='/topics/7/';f.document.querySelector('#channel-content').innerHTML='

服务暂不可用

';await f.app.route();assert.equal(f.calls.length,0);assert.equal(f.nodes.get('#channel-content').innerHTML,'

服务暂不可用

'); }); test('client configuration preserves the server content metadata and canonical',async()=>{ const path='/topics/7/',environment=seo.resolveSeoEnvironment({config:{foreverUrlAddr:'https://canonical.drama.test'}}),content={title:'服务端专题',description:'服务端简介',totalCount:72,pageSize:24}; const f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'channel',title:'服务端专题'},seo:{environment,content,path}});f.location.pathname=path;await f.app.route(); await f.app.applyConfig({config:{foreverUrlAddr:'https://other.drama.test'}}); assert.equal(f.document.title,'服务端专题 - 禁果短剧');assert.equal(f.document.value('link[rel="canonical"]'),'https://canonical.drama.test/topics/7/'); }); test('shared server configuration initializes downloads and contacts without refetching page configuration or footer',async()=>{ const shared={config:{config:{officialAppAddr:'https://app.test/download'}},footer:{footerInfo:{contactList:[{name:'服务时间',value:'每天'}]}},trackerConfigured:false}; const f=fixture(()=>assert.fail('public settings already arrived in HTML'),{shared}); await f.app.initializeShared();assert.equal(f.app.config.config.officialAppAddr,'https://app.test/download');assert.equal(f.calls.length,0); }); test('public back/forward documents keep working without reload; interactive private pages revalidate as fresh documents',async()=>{ const f=fixture();f.location.pathname='/shorts/';f.app.hidePage(true);assert.equal(f.app.signal.aborted,false); f.app.restorePage();assert.equal(f.reloads.length,0);assert.deepEqual(f.calls.map(item=>item.path),['restore-session']); const privatePage=fixture();privatePage.location.pathname='/info/deposit/';privatePage.app.hidePage(true);assert.equal(privatePage.app.signal.aborted,true); privatePage.app.restorePage();assert.equal(privatePage.reloads.length,1); });