Files
dtf-system/local/artwork_browser_test.mjs
Cauê Faleiros c9f8122600 refactor: give the frontend its own directory and split the API into routers
The Site's page sat at the repository root while its scripts lived in
local/static, a split with no reason behind it. They are together in web/ now,
with the page as index.html, which is also what the image serves.

app.py held the adapters, the configuration, the shared query helpers and
nineteen routes; customer.py held fourteen more but could not import from it
without a cycle, so it was wired by passing nine callables into install_routes.
Configuration and shared helpers move to local/runtime.py, the rules for
attaching artwork to an order move to local/artwork.py where a customer
correction and an operator final-file set can share them, and the routes become
seven routers under local/api. app.py is 48 lines that create the application,
apply the middleware and include them. Routers import downwards only.

Three faults came out of the extraction and are worth recording, because each
passed a check that looked sufficient. ast reports a function's line at the def,
so every decorator on the line above fell outside the extracted range: twelve
routes and the security middleware were defined but never registered, and the
files still imported and parsed cleanly. Names the old closure renamed on the
way in, and a Jsonb import, were missing in three modules. A name-resolution
pass over every new module found those; the route count matching the original
exactly, 32, is what confirmed the first.

The release gate's marker for the fake payment adapter pointed at app.py and the
adapter moved to runtime.py, so the gate passed while the condition it guards was
unchanged. That is the same silent decay 2.5 set out to fix. A test now asserts
every marker still matches something in its file, so the next move fails loudly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 17:22:00 -03:00

221 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('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')+"'");
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)){
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',
'--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));}