// Public anonymous calculation smoke test against an isolated QA Laravel DB. import fs from 'node:fs'; const baseUrl = process.argv[2] ?? 'http://127.0.0.1:18082/simulador-fiscal'; const outputPath = process.argv[3] ?? 'docs/investmoz/simulations/simulator-calculation-responsive-browser-2026-10-03.json'; const expectedQaTarget = process.argv[4] ?? null; const target = await ( await fetch('http://127.0.0.1:9224/json/new?about:blank', { method: 'PUT' }) ).json(); const ws = new WebSocket(target.webSocketDebuggerUrl); await new Promise((resolve, reject) => { ws.addEventListener('open', resolve, { once: true }); ws.addEventListener('error', reject, { once: true }); }); let id = 0; const pending = new Map(); const errors = []; const apiResponses = []; const simulationResponseShapes = []; const simulationRequestIds = new Map(); const responseBodyCaptures = []; const responsiveStates = []; let clientValidation = null; let fiscalHeadingFocus = null; ws.addEventListener('message', (event) => { const message = JSON.parse(event.data); if (message.method === 'Runtime.exceptionThrown') errors.push(message.params.exceptionDetails.text); if (message.method === 'Network.responseReceived') { const response = message.params.response; const path = new URL(response.url).pathname; if (path.startsWith('/api/v1/')) { const qaTarget = Object.entries(response.headers ?? {}).find( ([name]) => name.toLowerCase() === 'x-investmoz-qa-target', )?.[1]; apiResponses.push({ path, status: response.status, qaTarget: typeof qaTarget === 'string' ? qaTarget : null, }); if (path === '/api/v1/simulations') simulationRequestIds.set(message.params.requestId, { status: response.status, }); } } if ( message.method === 'Network.loadingFinished' && simulationRequestIds.has(message.params.requestId) ) { const response = simulationRequestIds.get(message.params.requestId); simulationRequestIds.delete(message.params.requestId); responseBodyCaptures.push( send('Network.getResponseBody', { requestId: message.params.requestId, }) .then(({ body, base64Encoded }) => { const json = JSON.parse( base64Encoded ? Buffer.from(body, 'base64').toString() : body, ); const data = json?.data; simulationResponseShapes.push({ httpStatus: response.status, outerKeys: Object.keys(json ?? {}).sort(), replayedType: typeof json?.replayed, replayed: json?.replayed === true, accessTokenLength: typeof json?.access_token === 'string' ? json.access_token.length : null, data: data && typeof data === 'object' ? { keys: Object.keys(data).sort(), idType: typeof data.id, status: data.status, versionNoType: typeof data.version_no, resultSnapshotType: data.result_snapshot === null ? 'null' : Array.isArray( data.result_snapshot, ) ? 'array' : typeof data.result_snapshot, } : null, }); }) .catch(() => {}), ); } if (!message.id) return; const request = pending.get(message.id); pending.delete(message.id); if (!request) return; if (message.error) request.reject(message.error); else request.resolve(message.result); }); const send = (method, params = {}) => new Promise((resolve, reject) => { const requestId = ++id; pending.set(requestId, { resolve, reject }); ws.send(JSON.stringify({ id: requestId, method, params })); }); const evaluate = async (expression) => { const response = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true, }); if (response.exceptionDetails) throw new Error( response.exceptionDetails.exception?.description ?? response.exceptionDetails.text, ); return response.result?.value; }; const waitFor = async (expression, description) => { for (let attempt = 0; attempt < 120; attempt++) { if (await evaluate(expression)) return; await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error(`Timed out waiting for ${description}`); }; const clickText = async (selector, text) => { const found = await evaluate(`(() => { const e = [...document.querySelectorAll(${JSON.stringify(selector)})] .find(node => node.innerText.trim().includes(${JSON.stringify(text)})); if (!e) return false; e.click(); return true; })()`); if (!found) throw new Error(`Could not find ${selector}: ${text}`); }; const choose = async (label, query, optionText) => { const opened = await evaluate(`(() => { const label = [...document.querySelectorAll('label')] .find(node => node.innerText.includes(${JSON.stringify(label)})); const input = label && document.getElementById(label.htmlFor); if (!input) return false; input.focus(); input.click(); return true; })()`); if (!opened) throw new Error(`Could not open combobox: ${label}`); await send('Input.insertText', { text: query }); const optionSelector = `[role="option"]`; await waitFor( `([...document.querySelectorAll(${JSON.stringify(optionSelector)})] .some(node => node.innerText.trim().includes(${JSON.stringify(optionText)})))`, `option ${optionText}`, ); await clickText(optionSelector, optionText); }; const fillNumber = async (field, value) => { const found = await evaluate(`(() => { const input = document.getElementById(${JSON.stringify(`field-${field}`)}); if (!input) return false; const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; setter.call(input, ${JSON.stringify(value)}); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); return true; })()`); if (!found) throw new Error(`Could not fill numeric field: ${field}`); }; const auditResponsiveState = async (state) => { const widths = [320, 360, 390, 768, 1024, 1440]; const viewports = []; for (const width of widths) { await send('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 768, }); await new Promise((resolve) => setTimeout(resolve, 80)); const layout = await evaluate(`(() => { const visible = e => e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden'; const name = e => e.getAttribute('aria-label') || (e.getAttribute('aria-labelledby') ? document.getElementById(e.getAttribute('aria-labelledby'))?.innerText?.trim() : null) || e.labels?.[0]?.innerText?.trim() || e.innerText?.trim() || e.title || null; const controls = [...document.querySelectorAll('input:not([type="hidden"]), select, textarea')] .filter(visible).map(e => ({ id: e.id || null, name: name(e) })); const buttons = [...document.querySelectorAll('button')].filter(visible).map(name); const links = [...document.querySelectorAll('a')].filter(visible).map(name); const overflowElements = [...document.querySelectorAll('body *')] .filter(visible).map(e => { const rect = e.getBoundingClientRect(); return { tag: e.tagName, id: e.id || null, className: String(e.className || '').slice(0, 100), left: Math.round(rect.left), right: Math.round(rect.right), width: Math.round(rect.width), text: (e.innerText || '').trim().slice(0, 80) }; }).filter(e => e.left < -1 || e.right > document.documentElement.clientWidth + 1) .sort((a, b) => (b.right - innerWidth) - (a.right - innerWidth)).slice(0, 8); return { requestedWidth: ${width}, viewport: innerWidth, layoutWidth: document.documentElement.clientWidth, documentScroll: document.documentElement.scrollWidth, bodyScroll: document.body.scrollWidth, unnamedControls: controls.filter(e => !e.name).map(e => e.id), unnamedButtons: buttons.filter(value => !value).length, unnamedLinks: links.filter(value => !value).length, visibleControls: controls.length, overflowElements, }; })()`); const tree = await send('Accessibility.getFullAXTree'); const namedRoles = new Set([ 'button', 'textbox', 'searchbox', 'combobox', 'checkbox', 'radioButton', 'link', 'slider', 'spinbutton', ]); layout.unnamedAxControls = tree.nodes .filter((node) => !node.ignored && namedRoles.has(node.role?.value)) .filter((node) => !node.name?.value?.trim()) .map((node) => node.role.value); layout.passed = layout.documentScroll <= layout.layoutWidth + 1 && layout.bodyScroll <= layout.layoutWidth + 1 && layout.unnamedControls.length === 0 && layout.unnamedButtons === 0 && layout.unnamedLinks === 0 && layout.unnamedAxControls.length === 0; viewports.push(layout); } await send('Emulation.clearDeviceMetricsOverride'); responsiveStates.push({ state, viewports, passed: viewports.every((item) => item.passed), }); return viewports.every((item) => item.passed); }; try { await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable'); await send('Page.navigate', { url: baseUrl }); await waitFor( "Boolean(document.querySelector('main #simulator-step-title'))", 'public simulator form', ); const purposeResponsive = await auditResponsiveState('purpose'); await choose('Escolha o percurso', 'Particular', 'Particular'); await choose('Nível de detalhe', 'Simples', 'Simples'); await choose('O que pretende fazer?', 'Apenas simular', 'Apenas simular'); await choose('Idioma preferido', 'Português', 'Português'); await clickText('button', 'Operação fiscal'); fiscalHeadingFocus = await evaluate(`(() => { const heading = document.querySelector('#simulator-step-title'); const style = heading ? getComputedStyle(heading) : null; return { focused: document.activeElement === heading, outlineStyle: style?.outlineStyle ?? null, outlineWidth: style?.outlineWidth ?? null, visible: Boolean(heading?.getClientRects?.().length), }; })()`); fiscalHeadingFocus.visibleIndicator = fiscalHeadingFocus.focused && fiscalHeadingFocus.visible && fiscalHeadingFocus.outlineStyle !== 'none' && fiscalHeadingFocus.outlineWidth !== '0px'; const fiscalResponsive = await auditResponsiveState('tax'); await choose('Ano do período fiscal', '2026', '2026'); await choose('Periodicidade/período de referência', 'Anual', 'Anual'); await choose('Componente que pretende explorar', 'INSS', 'INSS'); await choose( 'Modo de exploração fiscal', 'Cenário personalizado', 'Cenário personalizado de planeamento', ); await clickText('button', 'Validar e calcular no servidor'); await waitFor( "document.querySelector('#field-base')?.getAttribute('aria-invalid') === 'true'", 'client-side missing-input validation', ); const calculationRequestsBeforeCorrection = apiResponses.filter( (response) => response.path === '/api/v1/simulations', ).length; clientValidation = await evaluate(`(() => { const input = document.querySelector('#field-base'); const describedBy = (input?.getAttribute('aria-describedby') || '') .split(/\\s+/).filter(Boolean); const descriptions = describedBy.map(id => document.getElementById(id)); return { inputInvalid: input?.getAttribute('aria-invalid') === 'true', inputDescribedBy: describedBy, descriptionsExist: descriptions.length > 0 && descriptions.every(Boolean), descriptionsAreAlerts: descriptions.length > 0 && descriptions.every(node => node.getAttribute('role') === 'alert'), activeField: document.activeElement?.id || null, calculationRequestsBeforeCorrection: ${calculationRequestsBeforeCorrection}, }; })()`); clientValidation.accessibleErrors = clientValidation.inputInvalid && clientValidation.descriptionsExist && clientValidation.descriptionsAreAlerts && clientValidation.activeField === 'field-base' && clientValidation.calculationRequestsBeforeCorrection === 0; await fillNumber('base', '1000'); await fillNumber('rate', '10'); await fillNumber('reduction', '50'); await clickText('button', 'Validar e calcular no servidor'); try { await waitFor( "document.body.innerText.includes('Simulação concluída') && document.body.innerText.includes('50')", 'completed calculation result', ); } catch (error) { console.error( JSON.stringify( { visibleText: await evaluate('document.body.innerText'), clientValidation, apiResponses, simulationResponseShapes, javascriptExceptions: errors, }, null, 2, ), ); throw error; } await waitFor( "document.body.innerText.includes('não constitui cálculo nem conclusão fiscal') || document.body.innerText.includes('não determina taxas legais')", 'non-tax disclaimer', ); const resultResponsive = await auditResponsiveState('result'); const pageResult = await evaluate(`(() => ({ title: document.title, resultText: [...document.querySelectorAll('strong')].map(node => node.innerText.trim()), completed: document.body.innerText.includes('Simulação concluída'), taxDisclaimer: document.body.innerText.includes('não determina taxas legais') || document.body.innerText.includes('não constitui cálculo nem conclusão fiscal'), visibleAlerts: [...document.querySelectorAll('[role="alert"]')] .filter(node => node.getClientRects().length).map(node => node.innerText.trim()), anonymousIdentityNotice: document.body.innerText.includes('identidade e contactos não são enviados'), }))()`); await Promise.all(responseBodyCaptures); const calculationRequests = apiResponses.filter( (response) => response.path === '/api/v1/simulations', ); const failedResponses = apiResponses.filter( (response) => response.status < 200 || response.status >= 300, ); const qaTargetVerified = expectedQaTarget === null || (apiResponses.length > 0 && apiResponses.every( (response) => response.qaTarget === expectedQaTarget, )); const report = { checkedAt: new Date().toISOString(), route: baseUrl, browser: 'Microsoft Edge headless via CDP', scope: 'submissão anónima, validação acessível e UI responsiva nos estados finalidade/fiscal/resultado; sem dados pessoais ou interpretação fiscal', expectedQaTarget, qaTargetVerified, inputs: { fiscalPeriod: '2026', componentContext: 'inss', base: '1000', rate: '10', reduction: '50', }, clientValidation, fiscalHeadingFocus, responsiveStates, expectedArithmeticValue: '50', ...pageResult, calculationRequests, apiResponses, simulationResponseShapes, failedResponses, javascriptExceptions: errors, passed: pageResult.completed && pageResult.taxDisclaimer && pageResult.anonymousIdentityNotice && clientValidation.accessibleErrors && fiscalHeadingFocus.visibleIndicator && purposeResponsive && fiscalResponsive && resultResponsive && qaTargetVerified && pageResult.resultText.some( (value) => value === '50' || /^50\.0+$/.test(value), ) && calculationRequests.length === 1 && calculationRequests[0].status === 202 && failedResponses.length === 0 && errors.length === 0 && pageResult.visibleAlerts.length === 0, }; fs.writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`); console.log(JSON.stringify(report, null, 2)); if (!report.passed) process.exitCode = 1; } catch (error) { const report = { checkedAt: new Date().toISOString(), route: baseUrl, browser: 'Microsoft Edge headless via CDP', scope: 'submissão anónima, validação acessível e estado da execução; sem dados pessoais ou interpretação fiscal', clientValidation, fiscalHeadingFocus, responsiveStates, expectedQaTarget, calculationRequests: apiResponses.filter( (response) => response.path === '/api/v1/simulations', ), apiResponses, simulationResponseShapes, completed: false, pendingStatus: simulationResponseShapes.at(-1)?.data?.status ?? null, failure: error instanceof Error ? error.message : 'browser-smoke-failed', passed: false, }; fs.writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`); console.error(JSON.stringify(report, null, 2)); throw error; } finally { ws.close(); await fetch(`http://127.0.0.1:9224/json/close/${target.id}`); }