447 lines
19 KiB
JavaScript
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}`);
|
|
}
|