All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m22s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m34s
Every quote waited for an operator before it could be paid, so an order placed at night waited for the morning. A cart the Site priced is now approved when the quote is created, through the same server pricing the operator's approval uses (app/quote_review.py). Orders above QUOTE_AUTO_MAX_METRES (50 m) and items claiming a discount on art the Site could not analyse still wait for review; the Kanban shows which quotes were approved automatically and why the others wait. The grade is still computed in the browser (roadmap 3.2, 3.9), so the discount remains a customer-supplied value until the server computes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
16 KiB
JavaScript
196 lines
16 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"]');
|
|
// 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'});
|
|
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');
|
|
// 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);
|
|
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);
|
|
}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();}
|