// Browser smoke test with synthetic API failures; only page and current-draft GETs reach the isolated QA server. import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; const baseUrl = process.argv[2] ?? 'http://127.0.0.1:18088/simulador-fiscal'; const outputPath = process.argv[3] ?? path.join(os.tmpdir(), 'investmoz-simulator-api-errors-browser.json'); const expectedTarget = process.argv[4] ?? 'investmoz_browser_e2e_test:55443'; const target = await ( await fetch('http://127.0.0.1:9225/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 javascriptExceptions = []; const consoleErrors = []; const failedResources = []; const serverResponses = []; const apiResponses = []; ws.addEventListener('message', (event) => { const message = JSON.parse(event.data); if (message.method === 'Runtime.exceptionThrown') javascriptExceptions.push(message.params.exceptionDetails.text); if ( message.method === 'Runtime.consoleAPICalled' && message.params.type === 'error' ) consoleErrors.push( message.params.args .map((argument) => argument.value ?? argument.description ?? '') .join(' ') .slice(0, 500), ); if (message.method === 'Network.loadingFailed') failedResources.push({ error: message.params.errorText }); if (message.method === 'Network.responseReceived') { const response = message.params.response; const url = new URL(response.url); if (url.pathname.startsWith('/api/v1/')) apiResponses.push({ path: url.pathname, status: response.status }); if ( url.pathname === '/simulador-fiscal' || url.pathname === '/api/v1/simulation-drafts/current' ) serverResponses.push({ path: url.pathname, status: response.status, qaTarget: response.headers['X-Investmoz-QA-Target'] ?? response.headers['x-investmoz-qa-target'] ?? null, }); } if (!message.id) return; const request = pending.get(message.id); if (!request) return; pending.delete(message.id); 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 element = [...document.querySelectorAll(${JSON.stringify(selector)})] .find(node => node.innerText.trim().includes(${JSON.stringify(text)})); if (!element) return false; element.click(); return true; })()`); if (!found) throw new Error(`Could not find ${selector}: ${text}`); }; const choose = async (labelText, query, optionText) => { const opened = await evaluate(`(() => { const label = [...document.querySelectorAll('label')] .find(node => node.innerText.includes(${JSON.stringify(labelText)})); 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: ${labelText}`); if (query) await send('Input.insertText', { text: query }); await waitFor( `([...document.querySelectorAll('[role="option"]')] .some(node => node.innerText.trim().includes(${JSON.stringify(optionText)})))`, `option ${optionText}`, ); await clickText('[role="option"]', 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 mockApi = `(() => { window.__simApiUnhandled = []; window.addEventListener('unhandledrejection', event => { window.__simApiUnhandled.push(String(event.reason?.stack || event.reason || 'unknown').slice(0, 500)); }); const realFetch = window.fetch.bind(window); window.__simApiFailureTest = { catalog: 0, created: 0, poll: 0, draftWrites: 0 }; window.fetch = async (input, init = {}) => { const rawUrl = input instanceof Request ? input.url : String(input); const method = init.method || (input instanceof Request ? input.method : 'GET'); const url = new URL(rawUrl, location.href); if (url.pathname === '/api/v1/catalogs/jurisdictions' && url.searchParams.get('type') === 'country') { window.__simApiFailureTest.catalog += 1; if (window.__simApiFailureTest.catalog === 1) return new Response('temporary gateway error', { status: 502, headers: { 'Content-Type': 'text/html' }, }); return new Response(JSON.stringify({ data: [{ id: 'qa-country', label: 'País de teste QA' }] }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (url.pathname === '/api/v1/simulations' && method.toUpperCase() === 'POST') { window.__simApiFailureTest.created += 1; return new Response(JSON.stringify({ data: { id: 'f7000000-0000-4000-8000-000000000001', status: 'queued', version_no: 1 }, replayed: false, access_token: 'a'.repeat(64), }), { status: 202, headers: { 'Content-Type': 'application/json' } }); } if (url.pathname === '/api/v1/simulations/f7000000-0000-4000-8000-000000000001') { window.__simApiFailureTest.poll += 1; return new Response(JSON.stringify({ message: 'Estado do cálculo temporariamente indisponível.' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } if (url.pathname === '/api/v1/simulation-drafts' && method.toUpperCase() === 'POST') { window.__simApiFailureTest.draftWrites += 1; return new Response(JSON.stringify({ data: { id: 'f7000000-0000-4000-8000-000000000002', version_no: 1 }, }), { status: 201, headers: { 'Content-Type': 'application/json', ETag: '"qa-draft-v1"' }, }); } if ( url.pathname.startsWith('/api/v1/simulation-drafts/') && url.pathname !== '/api/v1/simulation-drafts/current' ) { if (method.toUpperCase() === 'PUT') { window.__simApiFailureTest.draftWrites += 1; return new Response(JSON.stringify({ data: { id: 'f7000000-0000-4000-8000-000000000002', version_no: 2 }, }), { status: 200, headers: { 'Content-Type': 'application/json', ETag: '"qa-draft-v2"' }, }); } if (method.toUpperCase() === 'DELETE') { window.__simApiFailureTest.draftWrites += 1; return new Response('', { status: 204 }); } } return realFetch(input, init); }; })()`; try { await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable'); await send('Log.enable'); await send('Page.addScriptToEvaluateOnNewDocument', { source: mockApi, }); await send('Page.navigate', { url: baseUrl }); await waitFor( "Boolean(document.querySelector('#simulator-step-title'))", 'simulator page', ); await waitFor( "performance.getEntriesByType('resource').some(entry => entry.name.includes('/api/v1/simulation-drafts/current'))", 'current-draft GET', ); const realTargetConfirmed = serverResponses.some( (response) => response.status === 200 && response.qaTarget === expectedTarget && response.path === '/simulador-fiscal', ); if (!realTargetConfirmed) throw new Error( 'The page response did not confirm the expected QA target.', ); 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'); await choose('Ano do período fiscal', '', '2026'); await choose('Periodicidade/período de referência', '', 'Anual'); await choose('Componente que pretende explorar', '', 'IRPS'); await choose('Categoria de rendimento', '', 'Trabalho'); await choose( 'Residência fiscal indicada pelo utilizador', '', 'Indico que sou residente', ); const countryLabel = 'País que indica como residência fiscal'; const countryOpen = await evaluate(`(() => { const label = [...document.querySelectorAll('label')] .find(node => node.innerText.includes(${JSON.stringify(countryLabel)})); const input = label && document.getElementById(label.htmlFor); if (!input) return false; input.focus(); input.click(); return true; })()`); if (!countryOpen) throw new Error( 'Could not open the fiscal residency country combobox.', ); await waitFor( 'window.__simApiFailureTest?.catalog === 1', 'first mocked catalog request', ); await waitFor( "[...document.querySelectorAll('[role=alert]')].some(node => node.innerText.includes('Não foi possível carregar as opções'))", 'catalog error alert', ); const catalogErrorState = await evaluate(`(() => ({ hasError: [...document.querySelectorAll('[role=alert]')].some(node => node.innerText.includes('Não foi possível carregar as opções')), hasEmptyMessage: document.body.innerText.includes('Sem opções correspondentes.'), retryVisible: [...document.querySelectorAll('button')].some(node => node.innerText.trim() === 'Tentar carregar novamente'), }))()`); if ( !catalogErrorState.hasError || catalogErrorState.hasEmptyMessage || !catalogErrorState.retryVisible ) throw new Error( 'Catalog failure UI did not distinguish failure from an empty catalog or offer retry.', ); await clickText('button', 'Tentar carregar novamente'); await waitFor('window.__simApiFailureTest?.catalog === 2', 'catalog retry'); await waitFor( "[...document.querySelectorAll('[role=option]')].some(node => node.innerText.includes('País de teste QA'))", 'catalog option after retry', ); await clickText('[role="option"]', 'País de teste QA'); await choose( 'Que resultado de remuneração pretende explorar?', '', 'Apuramento anual', ); await choose('Estado da documentação de despesas', '', 'Documentada'); await choose('Pretende calcular um cenário aritmético?', '', 'Sim'); await fillNumber('base', '1000'); await fillNumber('rate', '10'); await fillNumber('reduction', '50'); await clickText('button', 'Validar e calcular no servidor'); await waitFor( "[...document.querySelectorAll('[role=alert]')].some(node => node.innerText.includes('Estado do cálculo temporariamente indisponível'))", 'polling failure alert', ); await waitFor( "document.body.innerText.includes('O cálculo continua pendente.')", 'pending calculation message', ); const result = await evaluate(`({ mocks: window.__simApiFailureTest, catalogErrorAlert: [...document.querySelectorAll('[role=alert]')].some(node => node.innerText.includes('Não foi possível carregar as opções')), pollingErrorAlert: [...document.querySelectorAll('[role=alert]')].some(node => node.innerText.includes('Estado do cálculo temporariamente indisponível')), pendingStatusVisible: document.body.innerText.includes('O cálculo continua pendente.'), networkApiResponses: ${JSON.stringify(apiResponses)}, })`); const report = { checkedAt: new Date().toISOString(), route: baseUrl, browser: 'Microsoft Edge headless via CDP', qaTarget: expectedTarget, scope: 'real public page/current-draft GET on isolated QA; catalog, calculation, polling and draft writes intercepted in browser', catalogErrorState, result, serverResponses, javascriptExceptions, consoleErrors, passed: realTargetConfirmed && catalogErrorState.hasError && !catalogErrorState.hasEmptyMessage && catalogErrorState.retryVisible && result.mocks.catalog === 2 && result.mocks.created === 1 && result.mocks.poll === 1 && result.pollingErrorAlert && result.pendingStatusVisible && result.networkApiResponses.every( (response) => response.path === '/api/v1/simulation-drafts/current', ) && javascriptExceptions.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 (cause) { const report = { checkedAt: new Date().toISOString(), route: baseUrl, browser: 'Microsoft Edge headless via CDP', qaTarget: expectedTarget, failure: cause instanceof Error ? cause.message : 'browser-test-failed', page: await evaluate(`(() => ({ ready: document.readyState, title: document.title, url: location.href, appRoot: Boolean(document.querySelector('#app')), pageData: (() => { const script = document.querySelector('script[data-page]'); try { return JSON.parse(script?.textContent || 'null')?.component ?? null; } catch { return 'invalid-page-json'; } })(), simulatorTitle: Boolean(document.querySelector('#simulator-step-title')), rootTextLength: document.querySelector('#app')?.innerText?.length ?? 0, rootChildren: document.querySelector('#app')?.children.length ?? 0, rootAttributes: [...(document.querySelector('#app')?.attributes ?? [])].map(attribute => attribute.name), scriptCount: document.scripts.length, moduleScripts: [...document.scripts].filter(script => script.type === 'module').length, resourceCount: performance.getEntriesByType('resource').length, resourceSamples: performance.getEntriesByType('resource').slice(0, 12).map(entry => new URL(entry.name).pathname), unhandledRejections: window.__simApiUnhandled ?? [], mockedApi: window.__simApiFailureTest ?? null, visibleAlerts: [...document.querySelectorAll('[role=alert]')] .filter(element => element.getClientRects().length) .map(element => element.innerText.trim()), submittedValues: ['base', 'rate', 'reduction'].map(field => ({ field, value: document.getElementById('field-' + field)?.value ?? null, invalid: document.getElementById('field-' + field)?.getAttribute('aria-invalid') ?? null, })), }))()`).catch(() => null), serverResponses, javascriptExceptions, consoleErrors, failedResources: failedResources.slice(0, 20), passed: false, }; fs.writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`); console.error(JSON.stringify(report, null, 2)); throw cause; } finally { ws.close(); await fetch(`http://127.0.0.1:9225/json/close/${target.id}`); }