Navigation links carried data-modo-cta and opened loose artwork directly, so every entry point except the price card bypassed "Arquivo por metro". Dropping a PNG or JPG from the by-metre editor then switched the order to loose artwork and repriced it, while the same screen advertised PNG/JPG in its drop zone and marked that path as the cheaper one. Replace the guessing with a declaration: the product is the choice. #tipoEnvio shows ready sheet and loose artwork side by side with both prices, in all four modes, reversible until a file is attached and locked afterwards. sel() no longer reassigns modo, so a file can never change product or price on its own. Verify the declaration instead of trusting it. medirFolha returns dpiFolha, and an image without the pixels to span the film width at DPI_RECUSA is refused as a sheet, with the numbers shown and one click to send it as loose artwork. Previously a small image declared as a sheet was billed by the metre. Scope pintaCaminhos to #caminhos .cam: its global selector was clearing the new control. Move the checkout status out of #carr, which the success path hides, so a paid order still confirms itself to the customer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
216 lines
14 KiB
JavaScript
216 lines
14 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}));}
|
|
};
|
|
// 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"]');
|
|
// 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(`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);
|
|
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);
|
|
// 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);
|
|
// 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');
|
|
// 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.
|
|
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));}
|