Track production Studio acceptance scripts for Git delivery

This commit is contained in:
2026-10-28 15:18:26 +08:00
parent dd75e57b66
commit bfb646fcc9
2 changed files with 65 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import {writeFile} from 'node:fs/promises';
const base='http://8.211.154.134:18131';
async function api(path,body,method=body?'POST':'GET',status=200){const r=await fetch(base+path,{method,...(body?{headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:{})});assert.equal(r.status,status,path);return r.json()}
const catalog=await api('/studio/api/layouts');assert.equal(catalog.layouts.length,28);
const note=(await api('/api/notes/8eb8c76500580f7a4b56ba3c')).entry;
const asset=note.coverAssetId||note.assets.find(a=>a.kind==='image').id;
const preview=await fetch(base+'/api/preview?'+new URLSearchParams({source:'/api/media/'+asset,width:'960'}));assert.equal(preview.status,200);assert.equal(preview.headers.get('content-type'),'image/webp');const bytes=(await preview.arrayBuffer()).byteLength;assert.ok(bytes<=220000);
const results=[];
for(const layoutId of ['standard-01','ref-17']){
const schema=await api('/studio/api/layouts/'+layoutId);assert.ok(schema.elements.some(s=>s.id==='T01'));
const p={texts:{T01:{value:'API 验收 · 原生页面',fontFamily:'serif',fontWeight:600}},images:{I01:{assetId:asset,fit:'cover',cropX:35,cropY:65,zoom:1.2}},added:[{id:'user-api-check',kind:'text',role:'扩展正文',box:[10,120,60,9],direction:'horizontal',text:'通过 HTTP 保存并恢复',reading:'custom'}],layers:{'user-api-check':{z:20,rotation:2}}};
let d=(await api('/api/studio/designs',{schemaVersion:1,layoutId,name:'API 验收 '+layoutId,parameters:p},'POST',201)).design;
const id=d.id,postId=d.postId;
assert.deepEqual((await api('/api/studio/designs/'+id)).design.parameters,d.parameters);
const html=await fetch(base+d.url).then(r=>r.text());assert.ok(html.includes('API 验收'));assert.ok(html.includes('user-api-check'));assert.ok(html.includes('/api/preview?'));assert.ok(!html.includes('src="/api/media/'));
d=(await api('/api/studio/designs/'+id,{name:'API 验收修订 '+layoutId,parameters:p,revision:d.revision},'PATCH')).design;
assert.equal(d.postId,postId);assert.equal(d.revision,2);
await api('/api/studio/designs/'+id,{name:'过期写入',parameters:{},revision:1},'PATCH',409);
d=(await api('/api/studio/designs/'+id,{revision:2},'DELETE')).design;assert.equal(d.archived,true);
assert.equal((await fetch(base+d.url)).status,404);assert.equal((await api('/api/notes/'+postId)).entry.archived,true);
assert.deepEqual((await api('/api/studio/designs/'+id)).design.parameters,d.parameters);
assert.ok((await api('/api/studio/designs?archived=true&layoutId='+layoutId)).designs.some(v=>v.id===id));
d=(await api('/api/studio/designs/'+id,{revision:3,archived:false},'POST')).design;assert.equal(d.revision,4);assert.equal(d.archived,false);assert.equal((await fetch(base+d.url)).status,200);
results.push({layoutId,id,url:d.url,revision:d.revision,postId,parameters:p});
// Restore tested URL for visual inspection, then archive only these clearly named test records later.
}
for(const l of catalog.layouts){const r=await fetch(base+l.url);assert.equal(r.status,200,l.id);const html=await r.text();assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<img|data:image/.test(html));}
for(const slug of ['learning-skills-template','layout-selection']){assert.equal((await fetch(base+'/studio/ideas/'+slug)).status,200);const r=await fetch(base+'/studio/api/export/'+slug+'?format=svg');assert.equal(r.status,200);assert.match(r.headers.get('content-type'),/svg/);}
await writeFile('/tmp/studio-library/verification.json',JSON.stringify({results,preview:{asset,bytes,type:'image/webp'},pages:28},null,2));
console.log(JSON.stringify({crudFamilies:2,pages:28,conflicts:2,archiveRestore:2,compressedPreviewBytes:bytes,retainedToolbox:2,results:results.map(({parameters,...r})=>r)}));
+34
View File
@@ -0,0 +1,34 @@
const {readFile,mkdir,writeFile}=require('node:fs/promises');
const {createHmac,randomBytes}=require('node:crypto');
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
(async()=>{
const base='http://8.211.154.134:18131',origin=new URL(base).origin,dir='/tmp/studio-library/visual';await mkdir(dir,{recursive:true});
const config=JSON.parse(await readFile(process.env.AUTH_CONFIG_FILE,'utf8'));
const now=Math.floor(Date.now()/1000),part=o=>Buffer.from(JSON.stringify(o)).toString('base64url');
const content=part({alg:'HS256',typ:'JWT'})+'.'+part({iss:'prompt',aud:'prompt-web',sub:'shared',iat:now,exp:now+1200,jti:randomBytes(16).toString('hex')});
const token=content+'.'+createHmac('sha256',Buffer.from(config.jwtKey,'hex')).update(content).digest('base64url');
const browser=await chromium.launch({executablePath:'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});
const context=await browser.newContext({viewport:{width:1100,height:1000},deviceScaleFactor:1});
await context.route('**/*',async route=>{const u=new URL(route.request().url());if(u.origin===origin)return route.continue({headers:{...route.request().headers(),Authorization:'Bearer '+token}});if(['data:','blob:'].includes(u.protocol))return route.continue();return route.abort()});
const page=await context.newPage(),errors=[],images=[];
page.on('pageerror',error=>errors.push(error.message));
page.on('response',async response=>{if(response.request().resourceType()==='image'){const url=new URL(response.url());images.push({path:url.pathname+url.search,status:response.status(),type:response.headers()['content-type'],bytes:(await response.body().catch(()=>Buffer.alloc(0))).length})}});
const metrics=[];
for(let i=1;i<=27;i++){
const id='ref-'+String(i).padStart(2,'0');await page.goto(base+'/studio/layouts/'+id,{waitUntil:'networkidle'});await page.locator('article.native-layout').screenshot({path:dir+'/'+id+'.png'});
const m=await page.locator('article.native-layout').evaluate(el=>({id:el.dataset.layoutId,elements:el.querySelectorAll('[data-element]').length,width:el.clientWidth,height:el.clientHeight,overflow:[...el.querySelectorAll('.native-copy')].filter(e=>e.scrollHeight>e.clientHeight+2||e.scrollWidth>e.clientWidth+2).map(e=>e.closest('[data-element]').dataset.element)}));metrics.push(m);
}
await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.equal(await page.locator('.library-item').count(),27);await page.screenshot({path:dir+'/catalog-desktop.png',fullPage:true});
const verify=JSON.parse(await readFile('/tmp/studio-library/verification.json','utf8'));
for(const d of verify.results){await page.goto(base+d.url,{waitUntil:'networkidle'});assert.equal(await page.locator('img').count(),1);const current=await page.locator('img').evaluate(img=>({src:img.currentSrc,complete:img.complete,width:img.naturalWidth}));assert.ok(current.complete&&current.width>0);assert.equal(new URL(current.src).pathname,'/api/preview');await page.locator('article.native-layout').screenshot({path:dir+'/'+d.layoutId+'-saved.png'});
await page.getByRole('button',{name:'编辑页面',exact:true}).click();assert.equal(await page.locator('.workbench-top').count(),1);await page.screenshot({path:dir+'/'+d.layoutId+'-editor.png'});
}
for(const slug of ['learning-skills-template','layout-selection']){await page.goto(base+'/studio/ideas/'+slug,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/tool-'+slug+'.png',fullPage:true})}
await page.setViewportSize({width:390,height:844});await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.screenshot({path:dir+'/catalog-mobile.png',fullPage:true});
await page.goto(base+verify.results[0].url,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/standard-mobile.png',fullPage:true});
await page.goto(base+'/categories?category='+encodeURIComponent('文档入口'),{waitUntil:'networkidle'});await page.screenshot({path:dir+'/document-entries.png',fullPage:true});
assert.equal(images.filter(r=>new URL(r.path,base).pathname.startsWith('/api/media/')).length,0,'Display must never fetch originals');assert.equal(errors.length,0,JSON.stringify(errors));
await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));await browser.close();
console.log(JSON.stringify({pages:27,errors:errors.length,displayImages:images.length,originalImageRequests:0,overflow:metrics.filter(m=>m.overflow.length)}));
})().catch(e=>{console.error(e.message);process.exit(1)});