@@ -0,0 +1,401 @@
|
||||
// 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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user