web-statics/scripts/verify.mjs
2026-10-22 22:19:50 +08:00

91 lines
8.4 KiB
JavaScript

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};
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']++;
}
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(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 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'));
await pool(representative,3,async item=>{
const 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(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'});}
await page.close();
});
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();
}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;