229 lines
9.9 KiB
JavaScript
229 lines
9.9 KiB
JavaScript
// Isolated React component browser test. Requires Vite and Edge CDP on localhost.
|
|
import fs from 'node:fs';
|
|
|
|
const baseUrl =
|
|
process.argv[2] ??
|
|
'http://127.0.0.1:18117/tools/simulator-cashflow-preview.html';
|
|
const outputPath =
|
|
process.argv[3] ??
|
|
'docs/investmoz/simulations/simulator-cashflow-browser-2026-10-03.json';
|
|
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 nextId = 0;
|
|
const pending = new Map();
|
|
const exceptions = [];
|
|
const network = [];
|
|
ws.addEventListener('message', (event) => {
|
|
const message = JSON.parse(event.data);
|
|
if (message.method === 'Runtime.exceptionThrown')
|
|
exceptions.push(message.params.exceptionDetails.text);
|
|
if (message.method === 'Network.responseReceived')
|
|
network.push({
|
|
url: message.params.response.url,
|
|
status: message.params.response.status,
|
|
});
|
|
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 id = ++nextId;
|
|
pending.set(id, { resolve, reject });
|
|
ws.send(JSON.stringify({ id, 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 < 100; attempt++) {
|
|
if (await evaluate(`Boolean(${expression})`)) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
throw new Error(`Timed out waiting for ${description}`);
|
|
};
|
|
|
|
let report;
|
|
try {
|
|
await send('Page.enable');
|
|
await send('Runtime.enable');
|
|
await send('Network.enable');
|
|
await send('Page.navigate', { url: baseUrl });
|
|
await waitFor(
|
|
'document.querySelector(\'#cashflow-root section[aria-labelledby=\\"cashflow-title\\"]\')',
|
|
'the rendered cashflow component',
|
|
);
|
|
|
|
const fixedMode = await evaluate(`(() => {
|
|
const fixed = [...document.querySelectorAll('label')].find(label => label.innerText.includes('Valor mensal fixo'))?.querySelector('input[type="radio"]');
|
|
const note = [...document.querySelectorAll('[role="note"]')].map(node => node.innerText).find(text => text.includes('saldo total')) ?? null;
|
|
return { selected: fixed?.checked ?? false, monthlyInputs: [...document.querySelectorAll('label')].filter(label => label.innerText.includes('Imposto assumido por mês')).length, incompleteNote: note };
|
|
})()`);
|
|
|
|
const selectedSchedule = await evaluate(`(() => {
|
|
const label = [...document.querySelectorAll('label')].find(item => item.innerText.includes('Calendário variável por mês'));
|
|
const radio = label?.querySelector('input[type="radio"]');
|
|
radio?.click();
|
|
return Boolean(radio);
|
|
})()`);
|
|
if (!selectedSchedule)
|
|
throw new Error('Monthly schedule option is missing.');
|
|
await waitFor(
|
|
"[...document.querySelectorAll('label')].filter(label => label.innerText.includes('Imposto assumido') && /mês \\d+/u.test(label.innerText)).length === 12",
|
|
'12 labeled monthly assumption inputs',
|
|
);
|
|
|
|
const setMonth = async (month, value) =>
|
|
evaluate(`(() => {
|
|
const label = [...document.querySelectorAll('label')].find(item => item.innerText.includes('Imposto assumido') && item.innerText.includes('mês ${month}'));
|
|
const input = label?.querySelector('input');
|
|
if (!input) return false;
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
|
|
setter.call(input, '${value}');
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
return true;
|
|
})()`);
|
|
for (const [month, value] of [
|
|
[1, '250'],
|
|
[2, '500'],
|
|
]) {
|
|
if (!(await setMonth(month, value)))
|
|
throw new Error(`Could not update month ${month}.`);
|
|
}
|
|
await waitFor(
|
|
"[...document.querySelectorAll('tbody tr')].some(row => row.querySelector('th')?.innerText.trim() === '1' && row.querySelectorAll('td')[4]?.innerText.trim() === '250.00')",
|
|
'monthly assumption to affect the cash projection',
|
|
);
|
|
|
|
const partialSchedule = await evaluate(`(() => {
|
|
const rows = [...document.querySelectorAll('tbody tr')];
|
|
const taxFor = month => rows.find(row => row.querySelector('th')?.innerText.trim() === String(month))?.querySelectorAll('td')[4]?.innerText.trim() ?? null;
|
|
const note = [...document.querySelectorAll('[role="note"]')].map(node => node.innerText).find(text => text.includes('mês vazio')) ?? null;
|
|
const status = document.body.innerText.includes('Caixa conhecida antes de imposto em falta');
|
|
return { firstMonthTax: taxFor(1), secondMonthTax: taxFor(2), thirdMonthTax: taxFor(3), incompleteNote: note, incompleteSummary: status };
|
|
})()`);
|
|
|
|
for (let month = 3; month <= 12; month++) {
|
|
if (!(await setMonth(month, '0')))
|
|
throw new Error(`Could not explicitly set month ${month} to zero.`);
|
|
}
|
|
await waitFor(
|
|
"!document.body.innerText.includes('Há pelo menos um mês sem pressuposto de imposto')",
|
|
'complete projection after explicit zero assumptions',
|
|
);
|
|
const completeSchedule = await evaluate(`(() => ({
|
|
hasCompleteBalance: document.body.innerText.includes('Saldo projectado ao fim de 12 meses (MZN)'),
|
|
zeroTaxMonths: [...document.querySelectorAll('tbody tr')].slice(2).every(row => row.querySelectorAll('td')[4]?.innerText.trim() === '0.00'),
|
|
rowCount: document.querySelectorAll('tbody tr').length,
|
|
}))()`);
|
|
|
|
const responsive = [];
|
|
for (const width of [320, 360, 390, 768, 1024, 1440]) {
|
|
await send('Emulation.setDeviceMetricsOverride', {
|
|
width,
|
|
height: 900,
|
|
deviceScaleFactor: 1,
|
|
mobile: width < 768,
|
|
});
|
|
responsive.push(
|
|
await evaluate(`(() => ({
|
|
requested: ${width}, viewport: innerWidth,
|
|
documentWidth: document.documentElement.clientWidth,
|
|
documentScroll: document.documentElement.scrollWidth,
|
|
bodyScroll: document.body.scrollWidth,
|
|
hasHorizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1 || document.body.scrollWidth > document.documentElement.clientWidth + 1,
|
|
inputs: [...document.querySelectorAll('input:not([type="hidden"])')].filter(input => input.getClientRects().length).length,
|
|
unnamedInputs: [...document.querySelectorAll('input:not([type="hidden"])')].filter(input => input.getClientRects().length && !(input.labels?.length || input.getAttribute('aria-label'))).length,
|
|
}))()`),
|
|
);
|
|
}
|
|
await send('Emulation.clearDeviceMetricsOverride');
|
|
const ax = await send('Accessibility.getFullAXTree');
|
|
const unnamedAxControls = ax.nodes
|
|
.filter(
|
|
(node) =>
|
|
!node.ignored &&
|
|
[
|
|
'button',
|
|
'textbox',
|
|
'radioButton',
|
|
'spinbutton',
|
|
'link',
|
|
].includes(node.role?.value),
|
|
)
|
|
.filter((node) => !node.name?.value?.trim())
|
|
.map((node) => node.role.value);
|
|
|
|
report = {
|
|
checkedAt: new Date().toISOString(),
|
|
route: baseUrl,
|
|
browser: 'Microsoft Edge headless via CDP',
|
|
scope: 'React cashflow component only; no Laravel, API, database, or fiscal rules',
|
|
fixedMode,
|
|
partialSchedule,
|
|
completeSchedule,
|
|
responsive,
|
|
unnamedAxControls,
|
|
javascriptExceptions: exceptions,
|
|
networkFailures: network.filter((item) => item.status >= 400),
|
|
passed:
|
|
fixedMode.selected &&
|
|
fixedMode.monthlyInputs === 1 &&
|
|
Boolean(fixedMode.incompleteNote) &&
|
|
partialSchedule.firstMonthTax === '250.00' &&
|
|
partialSchedule.secondMonthTax === '500.00' &&
|
|
partialSchedule.thirdMonthTax === 'Indeterminado' &&
|
|
Boolean(partialSchedule.incompleteNote) &&
|
|
partialSchedule.incompleteSummary &&
|
|
completeSchedule.hasCompleteBalance &&
|
|
completeSchedule.zeroTaxMonths &&
|
|
completeSchedule.rowCount === 12 &&
|
|
responsive.every(
|
|
(item) =>
|
|
!item.hasHorizontalOverflow && item.unnamedInputs === 0,
|
|
) &&
|
|
unnamedAxControls.length === 0 &&
|
|
exceptions.length === 0 &&
|
|
network.every((item) => item.status < 400),
|
|
};
|
|
fs.writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
console.log(JSON.stringify(report, null, 2));
|
|
if (!report.passed) process.exitCode = 1;
|
|
} catch (error) {
|
|
report = {
|
|
checkedAt: new Date().toISOString(),
|
|
route: baseUrl,
|
|
browser: 'Microsoft Edge headless via CDP',
|
|
failure: error instanceof Error ? error.message : String(error),
|
|
partialReport: report,
|
|
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}`);
|
|
}
|