first commit
This commit is contained in:
140
local/browser_test.mjs
Normal file
140
local/browser_test.mjs
Normal file
@@ -0,0 +1,140 @@
|
||||
// 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',
|
||||
'--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 site=await page('http://localhost:'+(process.env.SITE_PORT||8080));
|
||||
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('local/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');
|
||||
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
|
||||
const qid=await site.eval('localStorage.getItem("dtf-quote")');
|
||||
const kanban=await page('http://localhost:'+(process.env.KANBAN_PORT||8081));
|
||||
await kanban.fill('#user',process.env.OPERATOR_USER||'operator');
|
||||
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
|
||||
await kanban.eval('document.getElementById("login").requestSubmit()');
|
||||
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');
|
||||
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Atualizar pedido local").click()');
|
||||
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 pago local").click()');
|
||||
await waitFor(async()=> (await site.text()).includes('Nenhuma cobrança real.'),'local 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`);
|
||||
// Click real transition buttons, including rerender after each move.
|
||||
for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){
|
||||
if(state==='fil'){
|
||||
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent==='Arquivos de produção').click();})()`);
|
||||
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('local/fixtures/local-test.cdr')]});
|
||||
await kanban.fill(`[data-order="${oid}"] input[placeholder="Nota da revisão"]`,'Browser test final file');
|
||||
await kanban.eval(`(()=>{const form=document.querySelector('[data-order="${oid}"] form');form.querySelector('[type=checkbox]').click();form.requestSubmit();})()`);
|
||||
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').version===2`),'final file approval');
|
||||
}
|
||||
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent===${JSON.stringify('→ '+title)}).click();})()`);
|
||||
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('http://localhost:'+(process.env.SITE_PORT||8080)+'/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'),'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();}
|
||||
Reference in New Issue
Block a user