Files
dtf-system/tests/browser_test.mjs
Cauê Faleiros c5db1375de
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m50s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images (push) Successful in 1m2s
test: reload only after the cart is saved, instead of after 800 ms
The browser suite reloaded the page a fixed 800 ms after the last field
was typed. The cart save waits for typing to stop and writes the files to
the browser's storage, so under load the reload came first and the cart was
not recovered ("persistent cart recovery", and once "Inspected target
navigated or closed"). It now waits until the cart is in storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:55:19 -03:00

218 lines
18 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');
// The check above can still pass on the page being replaced; wait for the new one's cards.
await waitFor(()=>site.eval(`!!document.querySelector('[data-modo="file"]') && document.readyState==='complete'`),'product cards after reload');
await site.click('[data-modo="file"]');
// Each product has its own page; the home's parts are not on it.
assert.deepEqual(await site.eval(`({path:location.pathname,page:document.documentElement.dataset.rota,
cards:getComputedStyle($('cards')).display,cart:getComputedStyle($('carr')).display})`),
{path:'/arquivo-por-metro',page:'produto',cards:'none',cart:'none'});
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');
// Reload only once the cart is in the browser's storage: the save waits for
// typing to stop and writes the files, so a fixed pause raced it.
await waitFor(()=>site.eval(`new Promise(done=>{const open=indexedDB.open('dtf-local-cart',1);
open.onerror=()=>done(false);
open.onsuccess=()=>{const all=open.result.transaction('cart').objectStore('cart').getAll();
all.onsuccess=()=>done(all.result.some(v=>v.items?.length===1));all.onerror=()=>done(false);};})`),'cart saved in the browser');
await site.call('Page.reload');
await waitFor(()=>site.eval('typeof pedido!=="undefined" && pedido.length===1'),'persistent cart recovery');
// Reloading a product page reopens that product; the cart is its own page.
assert.equal(await site.eval('modo'),'file');
await site.click('#cartLink');
await waitFor(()=>site.eval(`location.pathname==='/carrinho' && getComputedStyle($('carr')).display!=='none' && getComputedStyle($('foco')).display==='none'`),'cart page');
// Removing an item is one click and can be undone.
await site.click('[data-rmi="0"]');
// The empty state is painted on the next animation frame (site-steps.js).
await waitFor(()=>site.eval(`!$('carrVazio').hidden||document.documentElement.hasAttribute('data-sem-itens')`),'empty cart state');
assert.deepEqual(await site.eval(`({items:pedido.length,empty:!$('carrVazio').hidden||document.documentElement.hasAttribute('data-sem-itens'),undo:!$('desfazer').hidden})`),{items:0,empty:true,undo:true});
await site.click('#desfazerBtn');
assert.equal(await site.eval('pedido.length===1 && pedido[0].total===21.89 && $("desfazer").hidden'),true);
// The items come back after a reload; the customer's details do not.
assert.deepEqual(await site.eval(`['fCnpj','fZap','fMail'].map(id=>$(id).value)`),['','','']);
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),true);
await site.fill('#fCnpj','11222333000181');
await site.fill('#fZap','11999999999');
await site.fill('#fMail','local-browser@example.test');
assert.equal(await site.eval('pedido[0].localFiles[0].name'),'local-test.cdr');
// An unchecked item stays in the cart but is not paid for now.
await site.click('[data-sel="0"]');
assert.deepEqual(await site.eval(`({marcado:pedido[0].marcado,pagar:itensAPagar().length,zero:$('carrTot').textContent.includes('0,00'),row:document.querySelector('.item').classList.contains('fora')})`),
{marcado:false,pagar:0,zero:true,row:true});
await site.click('[data-sel="0"]');
assert.equal(await site.eval('pedido[0].marcado===true && itensAPagar().length===1'),true);
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{
// A cart the Site can price is approved at once: the customer pays now.
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'browser upload and automatic approval',45000);
// Payment is its own page: the cart form is gone and the order summary is shown.
assert.equal(await site.eval('location.pathname'),'/pagamento');
assert.equal(await site.eval('getComputedStyle(document.getElementById("carr")).display'),'none');
assert.equal(await site.eval('!document.getElementById("pagResumo").hidden && document.getElementById("pagResumo").textContent.includes("Total")'),true);
}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; an automatic approval is listed as such.
await waitFor(()=>kanban.eval('!document.getElementById("app").hidden'),'Kanban sign-in');
assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null);
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
await kanban.click('[data-tab="quotes"]');
await waitFor(()=>kanban.eval('document.querySelectorAll("#quote-filters button").length===2'),'quote filters');
await kanban.eval('document.querySelectorAll("#quote-filters button")[1].click()');
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-quote-pick="${qid}"]')`),'approved quote listed on Kanban');
assert.equal(await kanban.eval(`document.querySelector('[data-quote-pick="${qid}"]').textContent.includes('Aprovada automaticamente')`),true);
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);
}
// Undo a mistaken move from the panel: one stage back with an internal reason.
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-back="imp"]')`),'back button');
await kanban.click(`[data-order="${oid}"] [data-back="imp"]`);
await kanban.fill(`[data-order="${oid}"] form.reason input`,'Movido por engano (teste)');
await kanban.eval(`document.querySelector('[data-order="${oid}"] form.reason input').closest('form').requestSubmit()`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='imp'`),'undo to Imprimindo');
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move="fin"]')`),'move to Finalizado again');
await kanban.click(`[data-order="${oid}"] [data-move="fin"]`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='fin'`),'finished again');
// 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 → automatic approval → 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();}