159 lines
14 KiB
JavaScript
159 lines
14 KiB
JavaScript
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=>`<p>${message}</p>`,loading:()=>'<p>加载中</p>',activeBanners:()=>[],topicCard:()=>'<a href="/topics/1/">专题</a>',renderHome:()=>'<div data-home-loaded>接口首页</div>',card:(item,video,options)=>{cardCalls.push({item,video,options});return '<a href="/detail/1/">剧集</a>';},pagination:(_,page)=>`<span>第 ${page} 页</span>`};
|
||
return {};
|
||
};
|
||
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('商务合作:<a href="https://t.me/jbd125"'));assert.ok(page.html.includes('Telegram @jbd125'));
|
||
for(const text of ['用户反馈','feedback_group','business_team','mailto:','@example.com','ignored.example.com','客服'])assert.ok(!page.html.includes(text),text);
|
||
});
|
||
|
||
test('the contact section is identical whatever the backend configures',()=>{
|
||
const expected=infoPages.infoContactSection();
|
||
for(const config of [{config:{telegram:'',tgGroup:''},newAddressInfo:{telegram:'@business_team',tgGroup:'https://t.me/feedback_group'}},{config:{telegram:'https://t.me/primary'}},{config:{gmailAddr:'help@example.com'}},{config:{telegramAdd:'https://t.me/legacy'}}])
|
||
assert.equal(infoPages.infoContactSection(config),expected);
|
||
assert.ok(expected.includes('href="https://t.me/jbd125"'));assert.ok(!expected.includes(' hidden'));
|
||
});
|
||
|
||
test('configured contact rows never reach the page, so executable addresses cannot be injected',()=>{
|
||
const html=infoPages.infoContactSection({},[
|
||
{name:'<img onerror=alert(1)>',value:'<script>alert(1)</script>'},
|
||
{name:'注入',value:'javascript:alert(1)'},
|
||
{name:'数据链接',value:'data:text/html,<script>alert(1)</script>'},null,
|
||
]);
|
||
for(const text of ['<script','<img','javascript:','data:text'])assert.ok(!html.includes(text),text);
|
||
});
|
||
|
||
test('ranking data and native page links derive from the server route and preserve global positions',()=>{
|
||
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('<script'));assert.ok(!page.html.includes('href="#'));}
|
||
});
|
||
|
||
test('initial server channel and search content is retained without page requests or loading placeholders',async()=>{
|
||
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='<article>完整首屏正文</article>';
|
||
await f.app.route();assert.equal(f.nodes.get(selector).innerHTML,'<article>完整首屏正文</article>');assert.equal(f.calls.length,0);
|
||
await f.app.route();assert.deepEqual(f.reloads,[page.path]);assert.equal(f.nodes.get(selector).innerHTML,'<article>完整首屏正文</article>');
|
||
}
|
||
});
|
||
|
||
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='<p>服务暂不可用</p>';await f.app.route();assert.equal(f.calls.length,0);assert.equal(f.nodes.get('#channel-content').innerHTML,'<p>服务暂不可用</p>');
|
||
});
|
||
|
||
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);
|
||
});
|