188 lines
12 KiB
JavaScript
188 lines
12 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('dtf-site.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')+"'");
|
|
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;
|
|
}
|
|
if(['/privacy.js','/upload.js','/checkout.js','/cart.js'].includes(req.url)){
|
|
res.setHeader('Content-Type','text/javascript');
|
|
res.end(await readFile('local/static'+req.url));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',
|
|
'--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 AUTO!=='undefined'`),'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}));}
|
|
};
|
|
})()`);
|
|
// Reproduce the reported entry path, with no navigation workaround.
|
|
await click('[data-modo="file"]');
|
|
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);
|
|
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);
|
|
// 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);
|
|
// Explicit finished-sheet images retain their separate dimensions and price.
|
|
await click('#bVoltar');await click('[data-modo="file"]');
|
|
await click('#imagemComoFolha');await evaluate('sendImage()');
|
|
await waitFor(()=>evaluate('folhas.length===1 && !!folhas[0].previewSrc && metros===1.14'),'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);
|
|
await evaluate(`(()=>{const el=$('lista').querySelector('[data-repf]');el.value=2;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
|
await waitFor(()=>evaluate('metros===2.28'),'ready-sheet repetitions');
|
|
assert.equal(await evaluate(`document.querySelectorAll('.folhaPrevia').length`),1);
|
|
// Keep manual ready-sheet uploads intact and refuse ambiguous mixed batches.
|
|
await click('#bVoltar');await click('[data-modo="file"]');
|
|
await evaluate(`sel([new File(['sheet'],'sheet.cdr'),new File(['image'],'art.png')])`);
|
|
assert.deepEqual(await evaluate('[folhas.length,artes.length]'),[0,0]);
|
|
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');
|
|
// PNG drag/drop follows exactly the same artwork path; UV stays UV.
|
|
for(const [mode,expected] of [['file','avulsa'],['avulsa','avulsa'],['uvfile','uv']]){
|
|
await click('#bVoltar');await click('[data-modo="'+mode+'"]');
|
|
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.
|
|
await evaluate(`(()=>{window.realLoad=carregarImagem;window.delayed=[];
|
|
carregarImagem=f=>new Promise(resolve=>delayed.push(()=>realLoad(f).then(resolve)));
|
|
})()`);
|
|
await fill('[data-q]',5);
|
|
await waitFor(()=>evaluate('delayed.length===1'),'delayed preview');
|
|
await fill('[data-q]',7);
|
|
await evaluate(`carregarImagem=realLoad;delayed[0]()`);
|
|
await packed(7);
|
|
// 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));}
|