import fs from 'node:fs/promises'; import path from 'node:path'; import * as cheerio from 'cheerio'; import postcss from 'postcss'; import safeParser from 'postcss-safe-parser'; import valueParser from 'postcss-value-parser'; import { root, jsonFile, writeJSON, launchBrowser, blockedPath, pool } from './lib.mjs'; import { sites } from './sites.mjs'; import { createStaticServer } from './preview.mjs'; import { textNodes } from './translate.mjs'; const errors=[];const checks={files:0,links:0,resources:0,browserPages:0,status404:0,navigation:0,contact:0}; const selected=sites.filter(s=>!process.argv.slice(2).some(a=>!a.startsWith('--'))||process.argv.includes(s.id)); async function exists(file){try{return(await fs.stat(file)).isFile();}catch{return false;}} function cssUrls(text){const urls=[];valueParser(text).walk(node=>{if(node.type==='function'&&node.value==='url')urls.push(valueParser.stringify(node.nodes).replace(/^['"]|['"]$/g,''));});return urls;} async function linkCheck(site,from,value,kind){ if(!value||/^(?:#|data:|mailto:|tel:|javascript:)/i.test(value))return; if(/^(?:https?:)?\/\//.test(value)){if(kind!=='link')errors.push({site:site.id,file:from,kind,message:'Remote runtime resource',value});return;} const u=new URL(value,`http://local/${site.id}/${from}`);let target=decodeURIComponent(u.pathname); if(!target.startsWith('/'+site.id+'/')){errors.push({site:site.id,file:from,kind,message:'Cross-directory dependency',value});return;} if(blockedPath(target))errors.push({site:site.id,file:from,kind,message:'Backend link remains',value}); const file=path.join(root,target);if(!await exists(file)&&!await exists(path.join(file,'index.html')))errors.push({site:site.id,file:from,kind,message:'Missing target',value}); checks[kind==='link'?'links':'resources']++; } async function checkJskNavigation(page,item){ const original=page.url(); const groups=page.locator('.nav-menu > div'); const products=groups.nth(0);const about=groups.nth(1); const productTrigger=products.locator(':scope > a');const aboutTrigger=about.locator(':scope > a'); const links=products.locator('.nav-cont a'); const check=(ok,message)=>{checks.navigation++;if(!ok)errors.push({site:'jskcloud',file:item.file,message});}; async function visible(locator,message){ try{await locator.waitFor({state:'visible',timeout:2500});check(true,message);return true;} catch{check(false,message);return false;} } await fs.mkdir(path.join(root,'test-results','jskcloud'),{recursive:true}); for(const width of [1440,800]){ await page.setViewportSize({width,height:1000}); await productTrigger.hover(); await visible(links.first(),`Product dropdown did not open on desktop hover (${width}px)`); check(await links.count()===4,'Product dropdown must contain four product links'); for(const [index,target] of ['cloud.html','dedicated.html','balance.html','box.html'].entries()){ const link=links.nth(index); check(await link.isVisible(),`Product link is hidden: ${target} (${width}px)`); check(await link.evaluate(a=>a.href)===new URL(`/jskcloud/${item.language}/${target}`,original).href,`Incorrect product link: ${target}`); } if(width===1440)await page.screenshot({path:path.join(root,'test-results','jskcloud',item.file.replaceAll('/','-')+'.navigation.desktop.png')}); await aboutTrigger.hover(); await visible(about.locator('.nav-cont a').first(),`About dropdown did not open (${width}px)`); check(!await links.first().isVisible(),'Opening About must close Products'); await page.mouse.move(width-2,300); check(!await about.locator('.nav-cont a').first().isVisible(),'Desktop dropdown did not close on pointer exit'); await productTrigger.click(); await visible(links.first(),`Product dropdown did not remain open after clicking its trigger (${width}px)`); check(page.url()===original,'Product trigger must not navigate to the homepage'); await page.mouse.click(width-2,10); check(!await links.first().isVisible(),'Outside click did not close the dropdown'); } await productTrigger.focus();await productTrigger.press('ArrowDown'); await visible(links.first(),'ArrowDown did not open Products'); check(await links.first().evaluate(a=>a===document.activeElement),'ArrowDown did not focus the first product'); await page.keyboard.press('Escape'); check(!await links.first().isVisible(),'Escape did not close Products'); check(await productTrigger.evaluate(a=>a===document.activeElement),'Escape did not return focus to the trigger'); await productTrigger.press('Enter');await visible(links.first(),'Enter did not open Products'); await productTrigger.press('Space');check(!await links.first().isVisible(),'Space did not close Products'); for(const width of [390,768]){ await page.setViewportSize({width,height:844}); await page.evaluate(()=>window.scrollTo({top:0,left:0,behavior:'instant'})); const mobile=page.locator('#mobileMenuBtn');const menu=page.locator('.nav-menu'); await mobile.click();check(await menu.isVisible(),`Mobile navigation did not open (${width}px)`); check(!await links.first().isVisible(),'Mobile product dropdown must start collapsed'); await productTrigger.click();await visible(links.first(),`Mobile product dropdown did not expand (${width}px)`); check(await productTrigger.getAttribute('aria-expanded')==='true','Expanded Products must expose its state'); const bounds=await links.last().boundingBox(); check(!!bounds&&bounds.y>=0&&bounds.y+bounds.height<=844,`Mobile product links must fit below the header (${width}px)`); check(await page.evaluate(()=>window.scrollY)<=2,`Opening mobile Products scrolled the page (${width}px)`); if(width===390)await page.screenshot({path:path.join(root,'test-results','jskcloud',item.file.replaceAll('/','-')+'.navigation.mobile.png')}); await productTrigger.click();check(!await links.first().isVisible(),'Second mobile click did not collapse Products'); await productTrigger.click();await aboutTrigger.click(); await visible(about.locator('.nav-cont a').first(),'Mobile About dropdown did not expand'); check(!await links.first().isVisible(),'Mobile dropdowns must close their siblings'); await mobile.click();check(!await menu.isVisible(),'Mobile navigation did not close'); } await page.setViewportSize({width:1440,height:1000}); await productTrigger.hover(); if(await visible(links.first(),'Products did not reopen after switching to desktop')){ const target=await links.first().evaluate(a=>a.href); await Promise.all([page.waitForURL(target),links.first().click()]); check(page.url()===target,'Product link did not navigate to its static page'); await page.goto(original,{waitUntil:'networkidle'});await page.evaluate(()=>document.fonts.ready); } } async function checkJskContact(page,item){ const original=page.url(); const main=page.locator('.static-contact'); const check=(ok,message)=>{checks.contact++;if(!ok)errors.push({site:'jskcloud',file:item.file,message});}; const telegram=main.locator('a[href="https://t.me/JSKCDN999"]'); check(await telegram.count()===2,'Contact page must expose the Telegram account and contact button'); check(await main.locator('.static-contact-handle').textContent()==='@JSKCDN999','Incorrect contact account label'); for(const link of await telegram.all()){ check(await link.getAttribute('target')==='_blank','Telegram contact must open in a new tab'); check((await link.getAttribute('rel')||'').includes('noopener'),'Telegram contact must isolate its new tab'); } const docs=main.locator('a[href$="document.html"]'); const expectedDocs=new URL(`/jskcloud/${item.language}/document.html`,original).href; check(await docs.evaluate(a=>a.href)===expectedDocs,'Contact documentation link must preserve the current language'); for(const width of [1440,390,320]){ await page.setViewportSize({width,height:width===1440?1000:844}); await page.evaluate(()=>window.scrollTo({top:0,left:0,behavior:'instant'})); for(const link of [telegram.first(),telegram.last(),docs])check(await link.isVisible(),`Contact link hidden at ${width}px`); const layout=await page.evaluate(()=>({width:innerWidth,scroll:document.documentElement.scrollWidth})); check(layout.scroll<=layout.width+2,`Contact page overflows at ${width}px`); for(const method of await main.locator('.static-contact-method').all()){ const bounds=await method.boundingBox(); check(!!bounds&&bounds.x>=0&&bounds.x+bounds.width<=width,`Contact column falls outside the viewport at ${width}px`); } if(width!==320){ await fs.mkdir(path.join(root,'test-results','jskcloud'),{recursive:true}); await page.screenshot({path:path.join(root,'test-results','jskcloud',item.file.replaceAll('/','-')+`.contact.${width===1440?'desktop':'mobile'}.png`),fullPage:true}); } } await Promise.all([page.waitForURL(expectedDocs),docs.click()]); check(page.url()===expectedDocs,'Contact documentation button did not navigate to its static page'); } for(const site of selected){ const manifest=await jsonFile(path.join(root,site.id,'site-manifest.json')); for(const item of manifest.pages){ const html=await fs.readFile(path.join(root,site.id,item.file),'utf8');const $=cheerio.load(html);checks.files++; if(!$('body').text().trim())errors.push({site:site.id,file:item.file,message:'Empty body'}); if($('.js-buy,.add-cart').length)errors.push({site:site.id,file:item.file,message:'Backend purchase control remains'}); if(site.id==='jskcloud'){ const telegram=$('a[href]').filter((_,el)=>/^https?:\/\/(?:www\.)?t\.me\//i.test($(el).attr('href')||'')); if(telegram.toArray().some(el=>$(el).attr('href')!=='https://t.me/JSKCDN999'))errors.push({site:site.id,file:item.file,message:'Obsolete Telegram contact remains'}); if($('.aside-tools .tools-box-s a').length!==1)errors.push({site:site.id,file:item.file,message:'Floating support must have one current contact'}); if(/(?:^|\/)contact\.html$/.test(item.file)){ if($('.static-contact').length!==1||$('.static-contact-method').length!==2)errors.push({site:site.id,file:item.file,message:'Contact methods missing'}); if(/主题云|提交工单|如何充值|控制台|登录/.test($('.server-banner2,.static-contact').text()))errors.push({site:site.id,file:item.file,message:'Obsolete contact template or backend instructions remain'}); } } if(item.language==='en'){ const untranslated=textNodes($).map(x=>x.value.trim()).filter(Boolean); if(untranslated.length)errors.push({site:site.id,file:item.file,message:'Untranslated English content',examples:untranslated.slice(0,3)}); } for(const [selector,attr,kind] of [['a[href]','href','link'],['script[src]','src','resource'],['link[rel="stylesheet"],link[rel="icon"]','href','resource'],['img[src],video[src],source[src],audio[src]','src','resource'],['[data-src]','data-src','resource'],['video[poster]','poster','resource']]){ for(const el of $(selector).toArray())await linkCheck(site,item.file,$(el).attr(attr),kind); } for(const el of $('[srcset]').toArray()){const value=$(el).attr('srcset');if(!value.startsWith('data:'))for(const part of value.split(','))await linkCheck(site,item.file,part.trim().split(/\s+/)[0],'resource');} for(const el of $('[style]').toArray())for(const value of cssUrls($(el).attr('style')))await linkCheck(site,item.file,value,'resource'); for(const el of $('style').toArray())for(const value of cssUrls($(el).text()))await linkCheck(site,item.file,value,'resource'); if($('a.language-option').length<3)errors.push({site:site.id,file:item.file,message:'Language switcher missing'}); if(/\{\{\s*(?:lang\.|params\.|item\.)/.test(html))errors.push({site:site.id,file:item.file,message:'Unrendered template'}); } for(const asset of await jsonFile(path.join(root,site.id,'assets','manifest.json'),[])){ if(asset.error){if(!asset.omitted)errors.push({site:site.id,file:asset.file,message:asset.error});continue;} if(asset.type?.includes('text/css')){ const css=await fs.readFile(path.join(root,site.id,asset.file),'utf8'); for(const value of cssUrls(css))await linkCheck(site,asset.file,value,'resource'); const parsed=safeParser(css);for(const rule of parsed.nodes.filter(n=>n.type==='atrule'&&n.name==='import')){const p=valueParser(rule.params).nodes.find(n=>n.type!=='space');if(p?.type==='string')await linkCheck(site,asset.file,p.value,'resource');} } } console.log(`${site.id}: checked HTML, local links and resources`); } if(!process.argv.includes('--static-only')){ const server=createStaticServer();await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const base=`http://127.0.0.1:${server.address().port}`;let browser; try{ browser=await launchBrowser();const context=await browser.newContext({viewport:{width:1440,height:1000},serviceWorkers:'block'}); await context.route('**/*',async route=>{if(!route.request().url().startsWith(base)){errors.push({message:'External browser request',url:route.request().url()});return route.abort();}return route.continue();}); for(const site of selected){ const manifest=await jsonFile(path.join(root,site.id,'site-manifest.json'));const navigationOnly=process.argv.includes('--navigation-only');const contactOnly=process.argv.includes('--contact-only');const representative=manifest.pages.filter(p=>/^(?:(?:en|zh-cn|zh-hk)\/)?(?:index|dedicated|balance|box|cloud|about|contact|document|document-result|news|announce)\.html$/.test(p.file)||p.file.endsWith('/'+(site.id==='jskcloud'?'24':'1')+'.html')).filter(p=>(!navigationOnly||/(?:^|\/)index\.html$/.test(p.file))&&(!contactOnly||/(?:^|\/)contact\.html$/.test(p.file))); await pool(representative,navigationOnly||contactOnly?1:3,async item=>{ let page; try{ page=await context.newPage();page.on('pageerror',e=>errors.push({site:site.id,file:item.file,message:e.message}));page.on('response',r=>{if(r.status()>=400)errors.push({site:site.id,file:item.file,message:`Browser HTTP ${r.status()}`,url:r.url()});}); await page.goto(`${base}/${site.id}/${item.file}`,{waitUntil:'networkidle'});await page.evaluate(()=>document.fonts.ready);checks.browserPages++; if(site.id==='jskcloud'&&/(?:^|\/)index\.html$/.test(item.file))await checkJskNavigation(page,item); if(site.id==='jskcloud'&&/(?:^|\/)contact\.html$/.test(item.file))await checkJskContact(page,item); if(item.file==='index.html'||item.file==='dedicated.html'||item.file==='cloud.html'){ await fs.mkdir(path.join(root,'test-results',site.id),{recursive:true});await page.screenshot({path:path.join(root,'test-results',site.id,item.file+'.desktop.png'),fullPage:true}); await page.screenshot({path:path.join(root,'test-results',site.id,item.file+'.desktop.viewport.png')}); } if(item.file==='index.html'||item.file==='cloud.html'){ await page.setViewportSize({width:390,height:844});await page.waitForTimeout(200);await page.screenshot({path:path.join(root,'test-results',site.id,item.file+'.mobile.png'),fullPage:true}); await page.screenshot({path:path.join(root,'test-results',site.id,item.file+'.mobile.viewport.png')}); const overflow=await page.evaluate(()=>({width:innerWidth,scroll:document.documentElement.scrollWidth}));if(overflow.scroll>overflow.width+2)errors.push({site:site.id,file:item.file,message:'Mobile page overflows',...overflow}); if(item.file==='index.html'){ const button=page.locator(site.id==='jskcloud'?'#mobileMenuBtn':'a[href="#tm-dialog-mobile"]').first();if(await button.isVisible()){await button.click();await page.waitForTimeout(350);const open=await page.locator(site.id==='jskcloud'?'.nav-menu':'#tm-dialog-mobile').isVisible();if(!open)errors.push({site:site.id,file:item.file,message:'Mobile menu did not open'});} } } if(item.file.endsWith('document-result.html')){const input=page.locator('#static-search-query');await input.fill('zzzz_no_matching_document_903');if(await page.locator('#static-search-results a').count()!==0)errors.push({site:site.id,file:item.file,message:'Search did not filter'});await input.fill('');if(await page.locator('#static-search-results a').count()===0)errors.push({site:site.id,file:item.file,message:'Local search index empty'});} }catch(error){errors.push({site:site.id,file:item.file,message:error.message});} finally{await page?.close().catch(()=>{});} }); console.log(`${site.id}: checked ${representative.length} desktop/mobile browser pages`); for(const bad of ['console/','console/v1/common','clientarea/','cart/goods.htm?id=4','login.htm','regist.htm','finance.htm','plugin/27/ticket.htm','en/login.htm','this-page-does-not-exist.html']){ const r=await fetch(`${base}/${site.id}/${bad}`);checks.status404++;if(r.status!==404)errors.push({site:site.id,file:bad,message:`Expected 404, got ${r.status}`}); } } await context.close().catch(()=>{}); }finally{await browser?.close();await new Promise(resolve=>server.close(resolve));} } await writeJSON(path.join(root,'test-results','verification.json'),{checkedAt:new Date().toISOString(),checks,errors}); console.log(JSON.stringify({checks,errorCount:errors.length,errors:errors.slice(0,30)},null,2)); if(errors.length)process.exitCode=1;