Files
probably-rendering-one-more…/scripts/verify-studio-visual.cjs
T
codex-bot 7f397fd3a2
creative workspace review checks / verify-deployed-canvas (pull_request) Failing after 2m45s
creative workspace review checks / verify (pull_request) Has been cancelled
fix(ci): verify production readiness without network idle
2026-11-02 16:20:08 +08:00

108 lines
12 KiB
JavaScript

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 {navigateBrandDocument,visitBrowserDocument}=await import('../apps/waterfall-wiki/tests/brand-navigation.mjs');
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.setDefaultTimeout(60000);
page.on('pageerror',error=>errors.push({url:page.url(),message:error.message,stack:error.stack}));
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})}});
// Wait for the actual document and application, not an idle background network.
// Shared navigation recovery is limited to safe GETs; assertions are never retried.
const visitDocument=async(url,interactive=false,reload=false)=>{
if(interactive)await visitBrowserDocument(page,url,reload);
else await navigateBrandDocument(timeout=>page.goto(url,{waitUntil:'domcontentloaded',timeout}),url);
await page.waitForFunction(()=>Array.from(document.images).every(img=>img.complete&&img.naturalWidth>0));
console.log('Production document ready:',new URL(url).pathname);
};
// The retired catalogue is retained for existing API consumers, not a release UI gate.
// Explicit legacy/diagnostic runs still exercise its full overflow assertions.
const checkLegacy=process.argv.includes('--legacy-layouts')||process.argv.includes('--diagnose');
const metrics=[];
for(let i=1;i<=27&&checkLegacy;i++){
const id='ref-'+String(i).padStart(2,'0');await visitDocument(base+'/studio/layouts/'+id);await page.locator('article.native-layout').screenshot({path:dir+'/'+id+'.png',style:'.page-tools{visibility:hidden}'});
const m=await page.locator('article.native-layout').evaluate(el=>{
const overflow=[...el.querySelectorAll('.native-copy')].filter(e=>e.scrollHeight>e.clientHeight+2||e.scrollWidth>e.clientWidth+2);
return {id:el.dataset.layoutId,elements:el.querySelectorAll('[data-element]').length,width:el.clientWidth,height:el.clientHeight,overflow:overflow.map(e=>e.closest('[data-element]').dataset.element),overflowDetails:overflow.map(e=>{const style=getComputedStyle(e);return {id:e.closest('[data-element]').dataset.element,text:e.textContent,clientWidth:e.clientWidth,scrollWidth:e.scrollWidth,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,fontSize:style.fontSize,lineHeight:style.lineHeight,letterSpacing:style.letterSpacing,fontFamily:style.fontFamily,fontWeight:style.fontWeight,writingMode:style.writingMode}})};
});metrics.push(m);await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));
}
if(checkLegacy&&!process.argv.includes('--diagnose'))assert.deepEqual(metrics.filter(m=>m.overflow.length),[],'Default copy must fit every native slot');
await visitDocument(base+'/',true);await page.getByRole('heading',{name:/想法不该停在对话里/}).waitFor();await page.screenshot({path:dir+'/landing-desktop.png',fullPage:true});
// Capture the complete editorial home at both narrow breakpoints on the deployed server.
await page.getByRole('heading',{name:/给好奇心一点空间/}).waitFor();
for(const [width,name] of [[390,'landing-mobile'],[320,'landing-mobile-compact']]){
await page.setViewportSize({width,height:844});
assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1),'Landing fits narrow screens');
const entry=await page.getByRole('navigation',{name:'主导航'}).getByRole('link',{name:/进入工作台/}).boundingBox();
assert.ok(entry&&entry.x>=0&&entry.x+entry.width<=width,'Landing workspace entry remains inside the viewport');
await page.screenshot({path:dir+'/'+name+'.png',fullPage:true});
}
// Verify the public account flow in this fresh context, without submitting credentials.
await page.setViewportSize({width:1128,height:959});
await visitDocument(base+'/account?next=/workspace',true);
await page.locator('button[data-theme-ready="true"]').waitFor();
await page.getByLabel('邮箱地址',{exact:true}).waitFor();
assert.equal(await page.locator('aside').count(),0,'Authentication must not include a workspace sidebar');
assert.equal(await page.getByRole('link',{name:'创建文档',exact:true}).count(),0);
assert.equal(await page.locator('.endoleap-brand').count(),1,'Only one brand belongs on the account page');
const accountSections=await page.locator('#account-content > div > section').evaluateAll(elements=>elements.map(el=>{const r=el.getBoundingClientRect();return {x:r.x,right:r.right,width:r.width}}));
assert.equal(accountSections.length,2);
assert.ok(accountSections[0].right<=accountSections[1].x,'Brand panel and credentials occupy separate columns');
const accountFits=async()=>assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1&&document.documentElement.scrollHeight<=innerHeight+1),'Account document fits both viewport axes');
const accountControlsFit=async()=>assert.ok(await page.locator('#account-content input, #account-content button[type="submit"]').evaluateAll(elements=>elements.every(el=>{const r=el.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight})),'Account inputs and submit button remain visible, not merely clipped');
await accountFits();await accountControlsFit();
await page.screenshot({path:dir+'/account-desktop.png',fullPage:true});
await page.getByRole('button',{name:'创建账户',exact:true}).click();
await page.getByRole('heading',{name:'创建你的账户',exact:true}).waitFor();
await page.waitForFunction(()=>document.querySelector('#account-content').getAnimations({subtree:true}).every(animation=>animation.playState!=='running'));
await page.screenshot({path:dir+'/account-register.png',fullPage:true});
await page.getByRole('button',{name:'登录',exact:true}).click();
await page.waitForFunction(()=>document.querySelector('#account-content').getAnimations({subtree:true}).every(animation=>animation.playState!=='running'));
await page.setViewportSize({width:390,height:844});
await accountFits();await accountControlsFit();
await page.screenshot({path:dir+'/account-mobile.png',fullPage:true});
await page.setViewportSize({width:320,height:568});await accountFits();await accountControlsFit();
await page.screenshot({path:dir+'/account-mobile-compact.png',fullPage:true});
await page.setViewportSize({width:1100,height:1000});
await visitDocument(base+'/graphic',true);
assert.equal(new URL(page.url()).pathname,'/graphic');
await page.getByRole('button',{name:'添加文字',exact:true}).waitFor({state:'visible'});
assert.equal(await page.locator('.library-item').count(),0,'Old catalog is retired from the entry page');
await page.getByRole('button',{name:'添加文字',exact:true}).click();
await page.getByRole('textbox',{name:'文字内容',exact:true}).fill('Studio 线上验收');
await visitDocument(page.url(),true,true);
assert.ok(await page.evaluate(()=>JSON.parse(localStorage.getItem('studio.graphic.v1')).items.some(i=>i.text==='Studio 线上验收')));
await page.screenshot({path:dir+'/graphic-desktop.png',fullPage:true});
await visitDocument(base+'/infinite_canvas',true);
assert.equal(new URL(page.url()).pathname,'/infinite_canvas');
await page.getByRole('button',{name:'添加笔记',exact:true}).waitFor({state:'visible'});
await page.screenshot({path:dir+'/infinite-canvas-desktop.png',fullPage:true});
const verify=JSON.parse(await readFile('/tmp/studio-library/verification.json','utf8'));
for(const d of verify.results){await visitDocument(base+d.url);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',style:'.page-tools{visibility:hidden}'});
assert.equal(new URL(page.url()).pathname,'/studio/template-layout');
assert.equal(await page.locator('button,input,textarea,select,[contenteditable],.page-tools,.workbench-top').count(),0);
await page.locator('.native-copy').first().dblclick();await page.locator('.native-copy').first().dispatchEvent('contextmenu');
assert.equal(await page.locator('button,input,textarea,select,[contenteditable],.workbench-top').count(),0);
const plain=await browser.newContext({viewport:{width:1100,height:1000},javaScriptEnabled:false});
await plain.route('**/*',async route=>{const u=new URL(route.request().url());if(u.origin===origin)return route.continue({headers:{...route.request().headers(),Authorization:'Bearer '+token}});return route.abort()});
const direct=await plain.newPage();await navigateBrandDocument(timeout=>direct.goto(base+d.url,{waitUntil:'domcontentloaded',timeout}),base+d.url);assert.equal(await direct.locator('article[data-design-id="'+d.id+'"]').count(),1);assert.ok((await direct.locator('article').innerText()).includes('API 验收'));await plain.close();
}
for(const slug of ['learning-skills-template','layout-selection']){await visitDocument(base+'/studio/ideas/'+slug);await page.screenshot({path:dir+'/tool-'+slug+'.png',fullPage:true})}
await page.setViewportSize({width:390,height:844});await visitDocument(base+'/graphic',true);assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.screenshot({path:dir+'/graphic-mobile.png',fullPage:true});
await visitDocument(base+verify.results[0].url);await page.screenshot({path:dir+'/standard-mobile.png',fullPage:true});
await visitDocument(base+'/categories?category='+encodeURIComponent('文档入口'),true);await page.getByRole('navigation',{name:'系统标签分类',exact:true}).waitFor({state:'visible'});assert.equal(await page.locator('.discovery-card').count(),3);await page.waitForFunction(()=>Array.from(document.querySelectorAll('.discovery-card img')).every(img=>img.complete&&img.naturalWidth>0),null,{timeout:60000});await page.screenshot({path:dir+'/document-entries.png',fullPage:true});
await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));
assert.equal(images.filter(r=>new URL(r.path,base).pathname.startsWith('/api/media/')).length,0,'Display must never fetch originals');if(!process.argv.includes('--diagnose'))assert.equal(errors.length,0,JSON.stringify(errors));for(const image of images){if(/^\/(?:studio\/)?brand\/(?:logo-light|logo-dark|icon-dark)\.svg$/.test(new URL(image.path,base).pathname)){assert.equal(image.status,200);assert.match(image.type,/image\/svg\+xml/);continue;}assert.equal(new URL(image.path,base).pathname,'/api/preview');assert.equal(image.status,200);assert.equal(image.type,'image/webp');assert.ok(image.bytes>0&&image.bytes<=220000)}
await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));await browser.close();
console.log(JSON.stringify({diagnostic:process.argv.includes('--diagnose'),legacyLayoutsChecked:checkLegacy,legacyPages:metrics.length,errors:errors.length,displayImages:images.length,originalImageRequests:0,overflow:metrics.filter(m=>m.overflow.length)}));
})().catch(e=>{console.error(e.stack||e.message);process.exit(1)});