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'; 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(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; 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==='../brand')return brandModule; 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==='./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/','/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); 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 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('商务合作:{ 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 contact rows never reach the page, so executable addresses cannot be injected',()=>{ const html=infoPages.infoContactSection({},[ {name:'',value:''}, {name:'注入',value:'javascript:alert(1)'}, {name:'数据链接',value:'data:text/html,'},null, ]); for(const text of ['{ 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 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()=>{ 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:'每天'}]}}}; 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',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 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); });