// 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(){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'],'.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('.cdr')); await site.eval(`(()=>{recusa([new File(['test'],'bad.')]);})()`); 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'); await pause(800); 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'); 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();}