/** Run only on the confirmed ces-control runner against the designated deployed site. */ const {readFile,writeFile,mkdir}=require('node:fs/promises'); const {createHmac,randomBytes,createHash}=require('node:crypto'); const {chromium}=require('playwright-core'); const sharp=require('sharp'); const assert=require('node:assert/strict'); (async()=>{ const base='http://8.211.154.134:18131',id=process.env.STUDIO_DESIGN_ID,dir=process.env.STUDIO_EXPORT_DIR; assert.match(id||'',/^[0-9a-f-]{36}$/i);assert.ok(dir,'Set STUDIO_EXPORT_DIR to a persistent output directory');await mkdir(dir,{recursive:true}); let token=process.env.PROMPT_TOKEN; if(!token){const config=JSON.parse(await readFile(process.env.AUTH_CONFIG_FILE,'utf8')),now=Math.floor(Date.now()/1000),part=o=>Buffer.from(JSON.stringify(o)).toString('base64url');const payload=part({alg:'HS256',typ:'JWT'})+'.'+part({iss:'prompt',aud:'prompt-web',sub:'shared',iat:now,exp:now+1200,jti:randomBytes(16).toString('hex')});token=payload+'.'+createHmac('sha256',Buffer.from(config.jwtKey,'hex')).update(payload).digest('base64url')} const api=async path=>{const r=await fetch(base+path,{headers:{Authorization:'Bearer '+token},redirect:'error'});assert.equal(r.status,200,path);return r.json()}; const plan=await api('/api/studio/series/'+id+'/export'),{design}=await api('/api/studio/series/'+id); assert.equal(plan.revision,design.revision);assert.ok(plan.pages.length>=3);assert.equal(new Set(plan.pages.map(p=>p.width+':'+p.height)).size,1,'Use one aspect ratio throughout the series'); const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}); const evidence={id,revision:plan.revision,url:base+design.url,styleVersion:design.document.style.version,files:[],viewports:[],errors:[]}; try{ const context=await browser.newContext({viewport:{width:1080,height:1600},deviceScaleFactor:1}); await context.route('**/*',route=>{const u=new URL(route.request().url());return u.origin===base?route.continue({headers:{...route.request().headers(),Authorization:'Bearer '+token}}):route.abort()}); const page=await context.newPage();page.on('pageerror',e=>evidence.errors.push(e.message)); async function ready(){await page.evaluate(async()=>{await document.fonts.ready;await Promise.all([...document.images].map(i=>i.decode()))});assert.equal(await page.locator('button,input,textarea,select,[contenteditable]').count(),0)} async function metrics(){return page.locator('article.native-layout').evaluateAll(arts=>arts.map(el=>({width:el.clientWidth,height:el.clientHeight,text:[...el.querySelectorAll('.native-copy')].map(e=>({id:e.closest('[data-element]').dataset.element,overflow:e.scrollHeight>e.clientHeight+2||e.scrollWidth>e.clientWidth+2,font:getComputedStyle(e).fontFamily,value:e.textContent})),images:[...el.querySelectorAll('img')].map(i=>({src:i.currentSrc,width:i.naturalWidth,height:i.naturalHeight,displayWidth:i.clientWidth,displayHeight:i.clientHeight,zoom:Number((i.style.transform.match(/scale\(([^)]+)\)/)||[])[1]||1)}))}))) } for(const viewport of [{width:1200,height:1000},{width:390,height:844}]){await page.setViewportSize(viewport);await page.goto(base+design.url,{waitUntil:'networkidle'});await ready();assert.equal(await page.locator('.series-page').count(),plan.pages.length);assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));const m=await metrics();assert.ok(m.every(a=>a.text.every(t=>!t.overflow)),'Text overflow in '+viewport.width);await page.screenshot({path:dir+'/review-'+viewport.width+'.png',fullPage:true});evidence.viewports.push({viewport,metrics:m})} for(const p of plan.pages){await page.setViewportSize({width:p.width,height:p.height});await page.goto(base+p.url,{waitUntil:'networkidle'});await ready();const [m]=await metrics();assert.equal(m.width,p.width);assert.ok(Math.abs(m.height-p.height)<=1);assert.ok(m.text.every(t=>!t.overflow),'Export text overflow'); for(const i of m.images){assert.equal(new URL(i.src).pathname,'/api/preview');assert.equal(new URL(i.src).searchParams.get('profile'),'export');assert.ok(i.width>=i.displayWidth*i.zoom&&i.height>=i.displayHeight*i.zoom,'Source image too small for crop/zoom')} // Record actual glyph-rendering fonts, including the CJK fallback used by Chromium. const cdp=await context.newCDPSession(page);await cdp.send('DOM.enable');await cdp.send('CSS.enable');const root=await cdp.send('DOM.getDocument'),nodes=await cdp.send('DOM.querySelectorAll',{nodeId:root.root.nodeId,selector:'.native-copy'}),fonts=[]; for(const nodeId of nodes.nodeIds){const f=await cdp.send('CSS.getPlatformFontsForNode',{nodeId});fonts.push(f.fonts);assert.ok(f.fonts.length,'No rendered fonts')} await cdp.detach();const bytes=await page.locator('article.native-layout').screenshot({path:dir+'/'+p.filename,animations:'disabled'}),meta=await sharp(bytes).metadata();assert.equal(meta.width,p.width);assert.equal(meta.height,p.height);evidence.files.push({...p,bytes:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex'),fonts,metrics:m}); } assert.equal((await api('/api/studio/series/'+id)).design.revision,plan.revision,'Revision changed during export');assert.deepEqual(evidence.errors,[]); await writeFile(dir+'/manifest.json',JSON.stringify(evidence,null,2));await writeFile(dir+'/design.json',JSON.stringify(design,null,2));console.log(JSON.stringify({id,revision:plan.revision,pages:evidence.files.length,dir})); }finally{await browser.close()} })().catch(e=>{console.error(e.message);process.exit(1)});