Files
dtf-system/tests/artwork_browser_test.mjs
Cauê Faleiros 8dddf0fa25
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m31s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m0s
feat: redesign the Montagem page, with thumbnails, check chips and a DPI price scale
The product page is rebuilt around mockup 1. The two ways to send are
cards under the title; the PNG/CDR route choice is gone (it never changed
the price, and the picker now takes every allowed format). Each file card
has a thumbnail, its size, check chips with a plain sentence for any
problem, a "Ver detalhes da conferência" link and a copies stepper; a CDR
file says "Conferência manual · preço cheio" and asks for the length. The
buy box shows the price scale in DPI with the customer's step, the metre
and 10 cm length, the total and what the resolution saved, and "Sai em N
partes" only above 20 m. Folha já montada gets "Ampliar" instead of zoom.
Pricing, grade and checks are unchanged.

Fixes on top: the card thumbnail is a small image made once, not the full
preview re-parsed on every repaint; sizes from 1 GB up read in GB; a failed
upload is shown with "Tentar de novo" instead of retrying silently on every
cart change. The browser suites wait for the product and the reloaded page
instead of racing them, and the artwork suite delays imagemDaArte, which
the packing now uses, fixing the CI failure of f4aacb5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:36:41 -03:00

340 lines
22 KiB
JavaScript

// Isolated upload/editor regression: no API, database, storage, or Docker services.
// Run: node local/artwork_browser_test.mjs
// Optional: ARTWORK_FILE=/absolute/path/to/image.jpg to also inspect a real image.
import assert from 'node:assert/strict';
import {spawn} from 'node:child_process';
import {createServer} from 'node:http';
import {createHash} from 'node:crypto';
import {mkdtemp, readFile, writeFile, mkdir} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {resolve} from 'node:path';
const html=await readFile('web/index.html','utf8');
const hashes=[...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)]
.filter(m=>! /\bsrc\s*=/i.test(m[1]))
.map(m=>"'sha256-"+createHash('sha256').update(m[2]).digest('base64')+"'");
function pdfFixture({pages=1,media='[0 0 720 360]',crop='',rotate=0,unit=1}={}){
const objects=[
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids ['+Array.from({length:pages},(_,i)=>i+3+' 0 R').join(' ')+
'] /Count '+pages+' /MediaBox '+media+' >>',
...Array.from({length:pages},()=> '<< /Type /Page /Parent 2 0 R'+
(crop?' /CropBox '+crop:'')+(rotate?' /Rotate '+rotate:'')+
(unit!==1?' /UserUnit '+unit:'')+' >>')
];
const chunks=['%PDF-1.6\n%\xE2\xE3\xCF\xD3\n'], offsets=[0];
let length=Buffer.byteLength(chunks[0],'latin1');
for(let i=0;i<objects.length;i++){
offsets.push(length);
const part=(i+1)+' 0 obj\n'+objects[i]+'\nendobj\n';
chunks.push(part);length+=Buffer.byteLength(part,'latin1');
}
const xref=length;
chunks.push('xref\n0 '+(objects.length+1)+'\n0000000000 65535 f \n');
for(const offset of offsets.slice(1))chunks.push(String(offset).padStart(10,'0')+' 00000 n \n');
chunks.push('trailer\n<< /Size '+(objects.length+1)+
' /Root 1 0 R >>\nstartxref\n'+xref+'\n%%EOF\n');
return Buffer.from(chunks.join(''),'latin1');
}
const pdfData=options=>JSON.stringify(pdfFixture(options).toString('base64'));
const server=createServer(async(req,res)=>{
if(req.url.startsWith('/api/')){
res.setHeader('Content-Type','application/json');
res.end(JSON.stringify(req.url==='/api/session'?{cart_scope:'isolated-artwork-test'}:{customer:null}));
return;
}
if(req.url==='/'){
res.setHeader('Content-Type','text/html');
res.setHeader('Content-Security-Policy',`default-src 'self'; script-src 'self' ${hashes.join(' ')}; script-src-attr 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'`);
res.end(html);return;
}
// Serve any script the page asks for, resolved inside web/, rather than
// a hardcoded list: the Site's behaviour is split across several files and a
// list would silently 404 the next one added.
if(/^\/[\w.-]+\.js$/.test(req.url) || /^\/vendor\/pdf\.(worker\.)?min\.js$/.test(req.url)){
try{
const body=await readFile(resolve('web'+req.url));
res.setHeader('Content-Type','text/javascript');res.end(body);return;
}catch{ res.writeHead(404);res.end();return; }
}
res.writeHead(404);res.end();
});
await new Promise(r=>server.listen(0,'127.0.0.1',r));
const profile=await mkdtemp(tmpdir()+'/dtf-artwork-');
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
...(process.env.CHROME_NO_SANDBOX==='1'?['--no-sandbox']:[]),
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
],{stdio:['ignore','ignore','pipe']});
let stderr='',ws,next=0;
chrome.stderr.on('data',b=>stderr+=b);
const pending=new Map(),errors=[];
const pause=ms=>new Promise(r=>setTimeout(r,ms));
async function waitFor(fn,label){
const end=Date.now()+15000;
while(Date.now()<end){if(await fn())return;await pause(50);}
throw new Error('Timeout: '+label);
}
function call(method,params={}){
return new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
}
async function evaluate(expression){
const r=await call('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});
if(r.exceptionDetails)throw new Error(JSON.stringify(r.exceptionDetails));
return r.result.value;
}
const click=selector=>evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`);
async function fill(selector,value){
await evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${JSON.stringify(String(value))};el.dispatchEvent(new Event('input',{bubbles:true}));})()`);
}
async function packed(count){
await waitFor(()=>evaluate(`window.lastLayout?.pos.length===${count} && !!document.querySelector('#vArea canvas') && Math.abs(metros-window.lastLayout.altura/100)<0.0001`),'packed canvas and matching metres');
return evaluate(`({height:lastLayout.altura, pos:lastLayout.pos, metres:metros, billed:cobrar(metros), mode:modo, heading:$('montcabTit').textContent})`);
}
async function screenshot(name){
// Finish editing as a customer does when leaving the field, refreshing the
// item's quality/details as well as the already-live canvas.
await evaluate(`$('lista').querySelector('[data-cm]')?.dispatchEvent(new Event('change',{bubbles:true}))`);
await waitFor(()=>evaluate(`!!$('vArea').querySelector('canvas') && Math.abs(metros-montagemCm/100)<0.0001`),'screenshot layout');
await evaluate(`$('foco').scrollIntoView({behavior:'instant',block:'start'})`);
const r=await call('Page.captureScreenshot',{format:'png'});
await mkdir('output/local',{recursive:true});
await writeFile('output/local/'+name+'.png',Buffer.from(r.data,'base64'));
}
try{
let port;
await waitFor(async()=>{try{port=Number((await readFile(profile+'/DevToolsActivePort','utf8')).split('\n')[0]);return true;}catch{return false;}},'Chrome startup');
const tab=await(await fetch(`http://127.0.0.1:${port}/json/new?about:blank`,{method:'PUT'})).json();
ws=new WebSocket(tab.webSocketDebuggerUrl);
await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j;});
ws.onmessage=event=>{
const m=JSON.parse(event.data);
if(m.id){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(m.error):p.resolve(m.result);}
else if(m.method==='Runtime.exceptionThrown')errors.push(m.params.exceptionDetails);
};
await call('Runtime.enable');await call('Page.enable');
await call('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:1,mobile:false});
await call('Page.navigate',{url:`http://127.0.0.1:${server.address().port}/`});
await waitFor(()=>evaluate(`typeof sel==='function' && typeof pintaEntrega==='function'`),'Site scripts');
// Observe the actual engine, without replacing its placement or rendering.
await evaluate(`(()=>{
const original=encaixar;
encaixar=(pieces,width)=>{const result=original(pieces,width);
if(pieces.some(p=>p.a))window.lastLayout={altura:result.altura,pos:result.pos.map(p=>({x:p.x,y:p.y,w:p.w,h:p.h,rot:p.rot}))};
return result;};
window.sendImage=async(type='image/jpeg',drop=false)=>{
const c=document.createElement('canvas');c.width=200;c.height=400;
const ctx=c.getContext('2d');ctx.fillStyle='white';ctx.fillRect(0,0,200,400);
ctx.fillStyle='#222';ctx.fillRect(85,20,20,360);ctx.fillRect(25,100,140,20);
ctx.fillStyle='#e65c00';ctx.fillRect(130,280,45,80);
const blob=await new Promise(r=>c.toBlob(r,type));
const file=new File([blob],type==='image/jpeg'?'artwork.jpg':'artwork.png',{type});
const dt=new DataTransfer();dt.items.add(file);
if(drop)$('zona').dispatchEvent(new DragEvent('drop',{dataTransfer:dt,bubbles:true,cancelable:true}));
else{$('inp').files=dt.files;$('inp').dispatchEvent(new Event('change',{bubbles:true}));}
};
// A genuine mounted sheet spans the film width with printable resolution.
window.sendSheet=async()=>{
const cm=larguraFilme(), px=Math.ceil(cm/2.54*150);
const c=document.createElement('canvas');c.width=px;c.height=Math.round(px*0.6);
const ctx=c.getContext('2d');ctx.fillStyle='white';ctx.fillRect(0,0,c.width,c.height);
ctx.fillStyle='#222';ctx.fillRect(40,40,px/4,px/4);
const blob=await new Promise(r=>c.toBlob(r,'image/png'));
const dt=new DataTransfer();dt.items.add(new File([blob],'folha-montada.png',{type:'image/png'}));
$('inp').files=dt.files;$('inp').dispatchEvent(new Event('change',{bubbles:true}));
};
window.pickTipo=t=>document.querySelector('#tipoEnvio .cam[data-tipo="'+t+'"]').click();
})()`);
// Reproduce the reported entry path, with no navigation workaround.
// Navigation links must reach the chooser, never preselect a product.
assert.equal(await evaluate(`document.querySelectorAll('[data-modo-cta]').length`),0);
await click('[data-modo="file"]');
// Opening a product paints on the next frame; on a busy runner the check
// must wait for it rather than race it.
await waitFor(()=>evaluate(`!$('tipoEnvio').hidden`),'by-metre product open');
// By-metre opens declaring a mounted sheet; switching is explicit and priced.
assert.deepEqual(await evaluate(`({shown:!$('tipoEnvio').hidden,
on:[...document.querySelectorAll('#tipoEnvio .cam.on')].map(b=>b.dataset.tipo)})`),
{shown:true,on:['folha']});
await evaluate(`(()=>{const f=new File(['x'],'oversize.cdr');Object.defineProperty(f,'size',{value:5*1073741824+1});sel([f]);})()`);
assert.equal(await evaluate(`folhas.length===0 && $('recusa').textContent.includes('5 GB')`),true,
'files beyond the 5 GB upload limit are rejected before browser analysis');
// A multi-GB sheet is the normal order: graded from the pixel size in its
// header, never decoded. 6732 x 35433 px across 57 cm is 3 m at 300 DPI.
await evaluate(`(()=>{const h=new Uint8Array(33);h.set([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0,0,0,13,0x49,0x48,0x44,0x52]);
const v=new DataView(h.buffer);v.setUint32(16,6732);v.setUint32(20,35433);h.set([8,6,0,0,0],24);
const f=new File([h],'folha-grande.png');Object.defineProperty(f,'size',{value:3*1073741824});sel([f]);})()`);
await waitFor(()=>evaluate(`folhas.length===1 && !!folhas[0].an`),'large sheet graded from its header');
assert.deepEqual(await evaluate(`({grande:folhas[0].an.grande,dpi:folhas[0].an.dpi,alt:folhas[0].med.alt,preview:!!folhas[0].previewSrc})`),
{grande:true,dpi:300,alt:300,preview:false});
await evaluate(`folhas=[];pintaFolha()`);
await evaluate(`pickTipo('avulsa')`);
assert.equal(await evaluate('modo'),'avulsa');
await evaluate('sendImage()');
await waitFor(()=>evaluate(`artes.length===1 && !!artes[0].src`),'JPG artwork model');
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,width:artes[0].cm,quantity:artes[0].q})`),{mode:'avulsa',sheets:0,width:0,quantity:1});
await fill('[data-cm]',20);await fill('[data-q]',6);
let layout=await packed(6);
await waitFor(()=>evaluate(`itemAtual?.production?.sources[0]?.copies===6`),'per-file production record');
assert.deepEqual(await evaluate(`({version:itemAtual.production.version,source:itemAtual.production.sources[0]})`),
{version:2,source:{kind:'artwork',width_cm:20,length_cm:40,copies:6,
rotation_degrees:0,mirrored:false,measurement:'file'}});
assert.equal(await evaluate('itemAtual.production.placements.length'),6);
assert.equal(await evaluate('itemAtual.production.height_cm'),121);
assert.deepEqual(await evaluate('itemAtual.production.placements.map(p=>[p.source_index,p.copy_index,p.x_cm,p.y_cm])'),
[[0,0,0,0],[0,1,20.5,0],[0,2,0,40.5],[0,3,20.5,40.5],[0,4,0,81],[0,5,20.5,81]]);
assert.equal(layout.heading,'Montagem ao vivo');assert.equal(layout.height,121);
assert.equal(layout.billed,1.3);
assert.deepEqual(layout.pos.map(p=>[p.x,p.y]),[[0,0],[20.5,0],[0,40.5],[20.5,40.5],[0,81],[20.5,81]]);
assert.ok(layout.pos.every(p=>p.h/p.w===2 && p.x+p.w<=57));
await screenshot('artwork-packed');
await fill('[data-cm]',15);
await waitFor(()=>evaluate('lastLayout?.altura===60.5 && metros===0.605'),'width updates without blur');
await fill('[data-q]',9);layout=await packed(9);assert.equal(layout.height,91);
await click('[data-giro]');
await waitFor(()=>evaluate('lastLayout?.altura===23.5 && metros===0.235'),'rotation updates packing');
const beforeMirror=await evaluate(`$('vArea').querySelector('canvas').toDataURL()`);
await click('[data-esp]');
await waitFor(()=>evaluate(`$('vArea').querySelector('canvas').toDataURL()!==${JSON.stringify(beforeMirror)}`),'mirror updates pixels');
assert.equal(await evaluate('metros'),0.235);
await waitFor(()=>evaluate(`itemAtual?.production?.sources[0]?.mirrored===true`),'transforms in production record');
assert.deepEqual(await evaluate(`({rotation:itemAtual.production.sources[0].rotation_degrees,copies:itemAtual.production.sources[0].copies})`),
{rotation:90,copies:9});
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='rejected'`),'low resolution refusal');
assert.equal(await evaluate('cartPodeEnviar()'),false);
await fill('[data-cm]',3);
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='ok'`),'rotated DPI uses image height');
assert.equal(await evaluate('Math.round(dpiDe(artes[0]))'),339);
await click('[data-giro]');
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='warning'`),'unrotated resolution warning');
assert.equal(await evaluate('Math.round(dpiDe(artes[0]))'),169);
assert.equal(await evaluate('cartPodeEnviar()'),false);
await click('#cienteOk');
assert.equal(await evaluate('cartPodeEnviar()'),true);
await fill('[data-q]',8);
assert.equal(await evaluate('itemAtual===null'),true,'editing invalidates the old cart immediately');
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='warning'`),'edited warning');
assert.equal(await evaluate('cartPodeEnviar()'),false,'acknowledgement does not survive an edit');
await fill('[data-cm]',58);
await waitFor(()=>evaluate(`itemAtual===null && artes[0].cm===58`),'oversized width rejected');
assert.equal(await evaluate('cartPodeEnviar()'),false);
assert.equal(await evaluate(`(()=>{try{encaixar([{w:58,h:10,img:null}],57);return false}catch(error){return error instanceof RangeError}})()`),true,
'packing engine must not shrink an oversized source');
await click('[data-rm]');
assert.equal(await evaluate(`artes.length===0 && itemAtual===null && !cartPodeEnviar()`),true,
'removed artwork cannot remain in the cart');
// A transparent asymmetric image exposes masks that ignore user transforms.
// Its top-left quarter becomes bottom-right after a mirror and 90° turn.
assert.equal(await evaluate(`(()=>{
const image=document.createElement('canvas');image.width=100;image.height=200;
image.getContext('2d').fillRect(0,0,50,100);
const result=encaixar([{w:20,h:10,img:image,a:{giro:90,esp:true},travado:true}],57);
const m=result.pos[0].m;
return !m.bits[2*m.cw+2] && !m.bits[2*m.cw+30] && !!m.bits[15*m.cw+30];
})()`),true);
// A real finished sheet keeps its own dimensions and by-metre price.
await click('#bVoltar');await click('[data-modo="file"]');
await evaluate('sendSheet()');
await waitFor(()=>evaluate('folhas.length===1 && !!folhas[0].previewSrc && metros===0.342'),'ready-sheet image');
assert.deepEqual(await evaluate(`({mode:modo,art:artes.length,title:$('montcabTit').textContent,rate:precoBase(100)})`),{mode:'file',art:0,title:'Sua folha',rate:14.9});
assert.equal(await evaluate(`document.querySelectorAll('[data-cm]').length`),0);
// With a sheet loaded the declaration is locked; it cannot flip under the customer.
assert.equal(await evaluate(`document.querySelector('#tipoEnvio .cam[data-tipo="avulsa"]').disabled`),true);
await evaluate(`(()=>{const el=$('lista').querySelector('[data-repf]');el.value=2;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await waitFor(()=>evaluate('metros===0.684'),'ready-sheet repetitions');
assert.equal(await evaluate(`document.querySelectorAll('.folhaPrevia').length`),1);
await evaluate(`(()=>{folhas.push({f:new File(['manual'],'manual.cdr'),med:null,rep:1,m:1,an:null});pintaFolha();})()`);
await waitFor(()=>evaluate(`itemAtual?.nota===0 && itemAtual?.unit===TABELA[modo]`),'mixed analyzed and manual sheets use table pricing');
// An image too small to span the film is refused, never silently repriced.
await click('#bVoltar');await click('[data-modo="file"]');
await evaluate('sendImage()');
await waitFor(()=>evaluate(`!!document.getElementById('usarAvulsa')`),'ready-sheet refusal');
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,art:artes.length})`),{mode:'file',sheets:0,art:0});
await click('#usarAvulsa');
await waitFor(()=>evaluate(`modo==='avulsa' && artes.length===1`),'one-click recovery into loose artwork');
// Keep manual ready-sheet uploads intact.
await click('#bVoltar');await click('[data-modo="file"]');
await evaluate(`sel([new File(['sheet'],'sheet.cdr')])`);
await waitFor(()=>evaluate(`!!$('lista').querySelector('[data-comp]')`),'manual ready sheet');
await evaluate(`(()=>{const el=$('lista').querySelector('[data-comp]');el.value=1.01;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await waitFor(()=>evaluate('itemAtual?.total===21.89'),'unchanged manual pricing');
await evaluate(`folhas[0].semAnalise='<img src=x onerror=alert(1)>';pintaFolha()`);
assert.equal(await evaluate(`!$('lista').querySelector('img') && $('lista').textContent.includes('<img src=x')`),true,
'PDF parser errors are text, never executable markup');
// Parsed geometry honors inherited MediaBox, CropBox, rotation and UserUnit.
// Extra pages and unreadable PDFs must never fall through to manual pricing.
const pdfFile=(data,name='sheet.pdf')=>`new File([Uint8Array.from(atob(${data}),c=>c.charCodeAt(0))],${JSON.stringify(name)},{type:'application/pdf'})`;
const rotated=await evaluate(`medirFolha(${pdfFile(pdfData({media:'[0 0 720 1440]',crop:'[0 0 360 720]',rotate:90,unit:2}))})`);
assert.deepEqual(rotated,{larg:50.8,alt:25.4,fonte:'página do PDF · UserUnit 2'});
const rendered=await evaluate(`rasterizarPdf(${pdfFile(pdfData({media:'[0 0 720 1440]',crop:'[0 0 360 720]',rotate:90,unit:2}))},50.8,25.4).then(r=>({ok:!!r.tela,error:r.erro||null}))`);
assert.deepEqual(rendered,{ok:true,error:null});
const multi=await evaluate(`medirFolha(${pdfFile(pdfData({pages:2}))})`);
assert.equal(multi.rejected,true);assert.match(multi.reason,/2 páginas/);
const beyondSpec=await evaluate(`medirFolha(${pdfFile(pdfData({media:'[0 0 20000 720]'}))})`);
assert.equal(beyondSpec.rejected,true);assert.match(beyondSpec.reason,/508 cm/);
const broken=await evaluate(`medirFolha(new File(['broken'],'broken.pdf',{type:'application/pdf'}))`);
assert.equal(broken.rejected,true);
await click('#bVoltar');await click('[data-modo="file"]');
await evaluate(`sel([${pdfFile(pdfData({pages:2}),'two-pages.pdf')}])`);
await waitFor(()=>evaluate(`folhas.length===1 && !!folhas[0].measurementError`),'multipage PDF refusal');
assert.equal(await evaluate(`avaliar().pronto`),false);
assert.equal(await evaluate(`cartPodeEnviar()`),false);
assert.match(await evaluate(`$('lista').textContent`),/não pode ser orçado/);
await click('#bVoltar');await click('[data-modo="avulsa"]');
await evaluate(`sel([new File(['not an image'],'broken.png',{type:'image/png'})])`);
await waitFor(()=>evaluate('artes.length===1 && artes[0].decodeError===true'),'failed image decode');
await fill('[data-cm]',20);
await waitFor(()=>evaluate('itemAtual===null && !cartPodeEnviar()'),'undecodable image cannot be quoted');
// PNG drag/drop follows exactly the same artwork path; UV stays UV. Reaching it
// from a by-metre product is one declared click, and it is reversible.
for(const [mode,expected] of [['file','avulsa'],['avulsa','avulsa'],['uvfile','uv'],['uv','uv']]){
await click('#bVoltar');await click('[data-modo="'+mode+'"]');
if(await evaluate(`ehFolha()`))await evaluate(`pickTipo('avulsa')`);
await evaluate(`sendImage('image/png',true)`);
await waitFor(()=>evaluate('artes.length===1 && !!artes[0].src'),'PNG drop');
assert.equal(await evaluate('modo'),expected);
await fill('[data-cm]',10);await fill('[data-q]',4);await packed(4);
}
// An older image load must not overwrite a newer edit, even before debounce.
// The packing loads each artwork's image through imagemDaArte.
await evaluate(`(()=>{window.realLoad=imagemDaArte;window.delayed=[];
imagemDaArte=a=>new Promise(resolve=>delayed.push(()=>realLoad(a).then(resolve)));
})()`);
await fill('[data-q]',5);
await waitFor(()=>evaluate('delayed.length===1'),'delayed preview');
await fill('[data-q]',7);
await evaluate(`imagemDaArte=realLoad;delayed[0]()`);
await packed(7);
const timeoutCleanup=await evaluate(`(async()=>{
const realLoader=carregarPdfJs, realTimer=setTimeout, realWorker=temWorker;
let destroyed=false, fail;
carregarPdfJs=async()=>({getDocument:()=>({
promise:new Promise((_,reject)=>{fail=reject}),
destroy:()=>{destroyed=true;fail(new Error('cancelled'));return Promise.resolve()}
})});
temWorker=true;
window.setTimeout=(fn,ms)=>realTimer(fn,ms===30000?1:ms);
try{
const result=await rasterizarPdf({arrayBuffer:async()=>new ArrayBuffer(1)},10,10);
return {timedOut:result.erro==='demorou demais neste navegador',destroyed};
}finally{carregarPdfJs=realLoader;window.setTimeout=realTimer;temWorker=realWorker;}
})()`);
assert.deepEqual(timeoutCleanup,{timedOut:true,destroyed:true});
// Inspect the supplied local artwork, when requested, using the real file input.
if(process.env.ARTWORK_FILE){
await click('#bVoltar');await click('[data-modo="file"]');
const doc=await call('DOM.getDocument');
const input=await call('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#inp'});
await call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve(process.env.ARTWORK_FILE)]});
await waitFor(()=>evaluate('artes.length===1 && !!artes[0].src'),'supplied artwork');
await fill('[data-cm]',20);await fill('[data-q]',6);layout=await packed(6);
assert.ok(new Set(layout.pos.map(p=>p.x)).size>1);
assert.ok(new Set(layout.pos.map(p=>p.y)).size>1);
await screenshot('artwork-supplied');
console.log('Supplied image:',JSON.stringify(layout));
}
assert.deepEqual(errors,[]);
console.log('PASS: JPG/PNG upload and drop, packed copies, 5 mm gaps, aspect ratio, live width/quantity/rotation/mirror, metres, ready-sheet pricing/repeats, UV, stale renders, strict inline CSP.');
}catch(error){console.error(error);if(stderr)console.error(stderr.slice(-1200));process.exitCode=1;}
finally{ws?.close();chrome.kill();server.closeAllConnections();await new Promise(r=>server.close(r));}