Files
Investidor-V2/tools/simulator-api-errors-browser.mjs
T
nala e65cb26901
tests / ci (push) Canceled after 0s
varias alteracoes
2026-10-06 14:50:12 +02:00

402 lines
17 KiB
JavaScript

// 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('<html>temporary gateway error</html>', {
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}`);
}