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

447 lines
19 KiB
JavaScript

// 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}`);
}