Files
dtf-system/tests/browser_test.mjs
Cauê Faleiros 99a558ddd9
All checks were successful
Build and deploy / Validate source (push) Successful in 11s
Build and deploy / Integration suite on a real stack (push) Successful in 3m3s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m51s
feat: redesign the Kanban and keep test wording out of production
Kanban: tabs for production, quote review and payments/integrations; compact
cards with products, metres, print-file status, delivery and time in stage;
an order panel with stage progress, one main action, a correction reason in
place, items with a preview drawn from the approved layout, final-file
approval and the history as a timeline. Quote review gets a list and a pane;
payment issues resolve in place; integrations show their real state, Tiny's
connection with a read-only "Testar conexão", and a readable send log. The
previous Kanban is kept in git tag ui-v1 and is no longer served.

Production wording: the customer portal no longer says it is a local test
environment outside the local stack; the checkout no longer tells customers
to use the Kanban or shows internal stage codes; sign-in, session, quota and
print-file messages are Portuguese and never say "local". A simulated freight
price is refused outside the local stack until a real freight provider
exists, so production only offers pickup.

The browser suite drives the new tabs and panel and still checks the whole
upload, quote, payment and production journey. Full CI sequence passes locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:28:04 -03:00

172 lines
14 KiB
JavaScript

// Dependency-free Chrome DevTools smoke test. Node 22+ and Chrome required.
import {spawn} from 'node:child_process';
import {mkdtemp,readFile,writeFile,mkdir} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {resolve} from 'node:path';
import assert from 'node:assert/strict';
try {
for(const line of (await readFile('.env','utf8')).split('\n')) {
if(!line.startsWith('#') && line.includes('=')) {
const i=line.indexOf('=');process.env[line.slice(0,i)]??=line.slice(i+1);
}
}
}catch(e){if(e.code!=='ENOENT')throw e;}
const profile=await mkdtemp(tmpdir()+'/dtf-browser-');
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']:[]),
// Production uses HTTPS; internal Compose HTTP names need a secure context
// for Web Crypto during the upload and checkout journey.
...(process.env.CHROME_TRUST_TEST_ORIGINS==='1'
? ['--unsafely-treat-insecure-origin-as-secure=http://site,http://kanban'] : []),
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
],{stdio:['ignore','ignore','pipe']});
const pause=ms=>new Promise(r=>setTimeout(r,ms));
let stderr='';chrome.stderr.on('data',data=>stderr+=data.toString());
const clients=[];
async function waitFor(fn,description,timeout=30000){const end=Date.now()+timeout;while(Date.now()<end){if(await fn())return;await pause(200);}throw new Error('Timeout: '+description);}
class Page {
constructor(ws){this.ws=ws;this.next=0;this.pending=new Map();this.errors=[];
ws.onmessage=event=>{const d=JSON.parse(event.data);if(d.id){const p=this.pending.get(d.id);if(p){this.pending.delete(d.id);d.error?p.reject(d.error):p.resolve(d.result);}}else if(d.method==='Runtime.exceptionThrown')this.errors.push(d.params.exceptionDetails);};
}
call(method,params={}){return new Promise((resolve,reject)=>{const id=++this.next;this.pending.set(id,{resolve,reject});this.ws.send(JSON.stringify({id,method,params}));});}
async eval(expression){const result=await this.call('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(result.exceptionDetails)throw new Error(JSON.stringify(result.exceptionDetails));return result.result.value;}
async click(selector){await this.eval(`document.querySelector(${JSON.stringify(selector)}).click()`);}
async fill(selector,value,event='input'){await this.eval(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${JSON.stringify(value)};el.dispatchEvent(new Event(${JSON.stringify(event)},{bubbles:true}));})()`);}
async text(){return this.eval('document.body.innerText');}
async screenshot(path){const result=await this.call('Page.captureScreenshot',{format:'png',captureBeyondViewport:false});await writeFile(path,Buffer.from(result.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');
async function page(url){
const tab=await (await fetch('http://localhost:'+port+'/json/new?'+encodeURIComponent(url),{method:'PUT'})).json();
const ws=new WebSocket(tab.webSocketDebuggerUrl);await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j;});
const p=new Page(ws);clients.push(p);await p.call('Runtime.enable');await p.call('Page.enable');
await p.call('Emulation.setDeviceMetricsOverride',{width:1440,height:1000,deviceScaleFactor:1,mobile:false});
await waitFor(()=>p.eval('document.readyState === "complete"'),'page load');return p;
}
const siteOrigin=process.env.SITE_BROWSER_ORIGIN||'http://localhost:'+(process.env.SITE_PORT||8080);
const kanbanOrigin=process.env.KANBAN_BROWSER_ORIGIN||'http://localhost:'+(process.env.KANBAN_PORT||8081);
const site=await page(siteOrigin);
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'checkout bridge');
// Prove escaping itself, independently of the CSP's second line of defense.
await site.call('Page.setBypassCSP',{enabled:true});
await site.eval(`(()=>{window.xssProbe=0;abrir('file');sel([new File(['test'],'<img src=x onerror=window.xssProbe=1>.cdr')]);})()`);
await pause(500);
assert.equal(await site.eval('window.xssProbe'),0);
assert.equal(await site.eval('document.querySelectorAll("#lista img[onerror]").length'),0);
assert.ok((await site.text()).includes('<img src=x onerror=window.xssProbe=1>.cdr'));
await site.eval(`(()=>{recusa([new File(['test'],'bad.<img src=x onerror=window.xssProbe=1>')]);})()`);
await pause(200);assert.equal(await site.eval('window.xssProbe'),0);
await site.call('Page.setBypassCSP',{enabled:false});
await site.call('Page.reload');
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'reload after security probe');
await site.click('[data-modo="file"]');
await site.click('[data-cam="tabela"]');
const root=await site.call('DOM.getDocument');
const input=await site.call('DOM.querySelector',{nodeId:root.root.nodeId,selector:'#inp'});
await site.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('tests/fixtures/local-test.cdr')]});
await waitFor(()=>site.eval('!!document.querySelector("[data-comp]")'),'manual length field');
await site.fill('[data-comp]','1.01','change');
await waitFor(()=>site.eval('!!itemAtual'),'cart calculation');
assert.equal(await site.eval('itemAtual.total'),21.89);
await site.fill('#fCnpj','11222333000181');
await site.fill('#fZap','11999999999');
await site.fill('#fMail','local-browser@example.test');
await pause(800);
await site.call('Page.reload');
await waitFor(()=>site.eval('typeof pedido!=="undefined" && pedido.length===1'),'persistent cart recovery');
assert.equal(await site.eval('pedido[0].localFiles[0].name'),'local-test.cdr');
assert.equal(await site.eval('pedido[0].total'),21.89);
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
await site.click('#bPagar');
try{
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
}catch(error){
console.error('Checkout status:',await site.eval('document.getElementById("checkoutStatus")?.textContent'));
throw error;
}
const qid=await site.eval('localStorage.getItem("dtf-quote")');
const kanban=await page(kanbanOrigin);
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
await kanban.eval('document.getElementById("login").requestSubmit()');
// Quotes live in their own tab; the review pane shows the one picked.
await waitFor(()=>kanban.eval('!document.getElementById("app").hidden'),'Kanban sign-in');
await kanban.click('[data-tab="quotes"]');
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-quote-pick="${qid}"]')`),'quote listed on Kanban');
await kanban.click(`[data-quote-pick="${qid}"]`);
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban');
assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null);
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
await kanban.eval(`(()=>{const card=[...document.querySelectorAll('.review')].find(x=>x.textContent.includes(${JSON.stringify(qid.slice(0,8))}));card.querySelector('[type=checkbox]').click();card.querySelector('form').requestSubmit();})()`);
await waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval');
assert.equal(await site.eval('pedido[0].production.sources[0].copies'),1);
await site.eval('pedido[0].production.sources[0].copies=2;pintaPedido()');
await waitFor(async()=> (await site.text()).includes('O carrinho mudou'),'same-price production edit invalidates quote');
await site.eval('pedido[0].production.sources[0].copies=1;pintaPedido()');
await site.fill('#fMail','changed-browser@example.test');
await waitFor(async()=> (await site.text()).includes('O carrinho mudou'),'quote invalidated by cart edit');
assert.equal(await site.eval('[...document.querySelectorAll("button")].some(x=>x.textContent==="Criar pedido de teste")'),false);
await site.fill('#fMail','local-browser@example.test');
await site.eval('window.dtfCheckout()');
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'approved quote displayed');
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Criar pedido de teste").click()');
await waitFor(async()=> (await site.text()).includes('Pedido #'),'test payment');
await kanban.click('#refresh');
await waitFor(()=>kanban.eval(`board.orders.some(o=>o.quote_id===${JSON.stringify(qid)})`),'paid card');
const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);
assert.deepEqual(await kanban.eval(`(()=>{const spec=board.orders.find(o=>o.id===${JSON.stringify(oid)}).snapshot.items[0].production;const source=spec.sources[0];return {kind:source.kind,copies:source.copies,length:Number(source.length_cm),height:Number(spec.height_cm),placed:spec.placements.length}})()`),
{kind:'sheet',copies:1,length:101,height:101,placed:1});
// Open the order's panel from its card, then click the real transition
// buttons there, including the rerender after each move.
await kanban.click('[data-tab="board"]');
await kanban.click(`[data-card="${oid}"]`);
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move]')`),'order panel');
for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){
if(state==='fil'){
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-final-item]')`),'final upload controls');
const doc=await kanban.call('DOM.getDocument');
const input=await kanban.call('DOM.querySelector',{nodeId:doc.root.nodeId,selector:`[data-order="${oid}"] [data-final-item]`});
await kanban.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('tests/fixtures/local-test.cdr')]});
await kanban.fill(`[data-order="${oid}"] input[placeholder="Nota da revisão"]`,'Browser test final file');
await kanban.eval(`(()=>{const check=document.querySelector('[data-order="${oid}"] [data-confirm]');check.click();check.closest('form').requestSubmit();})()`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').version===2`),'final file approval');
}
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move="${state}"]')`),'move to '+title);
await kanban.click(`[data-order="${oid}"] [data-move="${state}"]`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='${state}'`),'transition '+state);
}
// Reload proves the board is persisted on the backend.
await kanban.call('Page.reload');
await waitFor(()=>kanban.eval(`typeof board!=='undefined' && !!board && board.orders.some(o=>o.id==='${oid}'&&o.state==='fin')`),'persisted board');
await mkdir('output/local',{recursive:true});
await site.eval('window.scrollTo({top:0,behavior:"instant"})');
await waitFor(()=>site.eval('window.scrollY===0'),'screenshot scroll position');
await site.screenshot('output/local/site.png');
await kanban.screenshot('output/local/kanban.png');
const portal=await page(siteOrigin+'/portal.html?order='+oid);
await waitFor(async()=> (await portal.text()).includes('Finalizado'),'customer order tracking');
await portal.fill('#cnpj','11222333000181');await portal.fill('#phone','11999999999');
await portal.fill('#register-email','browser-'+Date.now()+'@example.test');
await portal.fill('#register-password','local-browser-password-123');
await portal.eval('document.getElementById("register").requestSubmit()');
await waitFor(()=>portal.eval('document.getElementById("auth").hidden'),'customer registration');
assert.ok((await portal.text()).includes('Finalizado'));
await portal.screenshot('output/local/portal.png');
// A logout must clear draft file blobs and metadata, including other open Site tabs.
await portal.eval(`(async()=>{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onsuccess=()=>resolve(r.result);r.onerror=reject;});await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').put({items:[new File(['private'],'private.cdr')],expires:Date.now()+86400000},'security-fixture');tx.oncomplete=resolve;tx.onerror=reject;});db.close();})()`);
await portal.click('#logout');
await waitFor(()=>portal.eval('document.getElementById("logout")?.hidden===true'),'customer logout');
let stored;
await waitFor(async()=>{stored=await portal.eval(`(async()=>{try{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});const n=await new Promise((resolve,reject)=>{const r=db.transaction('cart').objectStore('cart').count();r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});db.close();return n;}catch{return -1;}})()`);return stored>=0;},'IndexedDB available after Clear-Site-Data');
assert.equal(stored,0);
assert.deepEqual(portal.errors,[]);
assert.deepEqual(site.errors,[]);assert.deepEqual(kanban.errors,[]);
console.log('PASS: browser Site upload → operator quote → local paid order → all main Kanban states → reload persistence. Order '+oid);
console.log('Screenshots: output/local/site.png and output/local/kanban.png');
console.log('PASS: filename XSS escaping with CSP bypassed, no stored operator password, logout clears browser file blobs.');
}catch(error){console.error(error);if(stderr)console.error(stderr.slice(-1500));process.exitCode=1;}
finally{for(const p of clients)p.ws.close();chrome.kill();}