402 lines
17 KiB
JavaScript
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}`);
|
|
}
|