Files
Investidor-V2/tools/simulator-a11y-browser.mjs
T
nala 1106509116
tests / ci (push) Canceled after 0s
vrsao 2
2026-10-08 14:02:01 +02:00

265 lines
10 KiB
JavaScript

// Read-only responsive/accessibility smoke test against an isolated local Laravel page and Edge CDP.
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-browser-audit-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 = [];
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 targetHeader = Object.entries(response.headers ?? {}).find(
([name]) => name.toLowerCase() === 'x-investmoz-qa-target',
)?.[1];
apiResponses.push({
path,
status: response.status,
qaTarget:
typeof targetHeader === 'string' ? targetHeader : null,
});
}
}
if (!message.id) return;
const request = pending.get(message.id);
pending.delete(message.id);
if (message.error)
request.reject(
new Error(
`CDP ${request.method}: ${message.error.message ?? 'request failed'}`,
),
);
else request.resolve(message.result);
});
const send = (method, params = {}) =>
new Promise((resolve, reject) => {
const requestId = ++id;
pending.set(requestId, { resolve, reject, method });
ws.send(JSON.stringify({ id: requestId, method, params }));
});
const evaluate = async (expression) => {
let response;
try {
response = await send('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
});
} catch (error) {
throw new Error(
`Runtime.evaluate [${expression.slice(0, 120)}]: ${error.message}`,
);
}
if (response.exceptionDetails) throw new Error('Page evaluation failed');
return response.result?.value;
};
const waitFor = async (expression) => {
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 ${expression}`);
};
const waitForApiResponse = async () => {
for (let attempt = 0; attempt < 100; attempt++) {
if (apiResponses.length > 0) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error('Timed out waiting for the public draft API response');
};
try {
await send('Page.enable');
await send('Runtime.enable');
await send('Network.enable');
await send('Page.navigate', { url: baseUrl });
await waitFor("document.querySelector('main #simulator-step-title')");
await waitForApiResponse();
const dimensions = [];
for (const width of [390, 768, 1024, 1440]) {
await send('Emulation.setDeviceMetricsOverride', {
width,
height: 900,
deviceScaleFactor: 1,
mobile: width < 768,
});
await new Promise((resolve) => setTimeout(resolve, 100));
const result = await evaluate(`(() => {
const controls = [...document.querySelectorAll('input:not([type="hidden"]), select, textarea, button')]
.filter(e => e.getClientRects().length);
const fixed = controls.filter(e => getComputedStyle(e).position === 'fixed');
return {
viewport: innerWidth,
document: document.documentElement.clientWidth,
documentScroll: document.documentElement.scrollWidth,
bodyScroll: document.body.scrollWidth,
fixedControls: fixed.length,
controls: controls.length,
};
})()`);
dimensions.push(result);
}
await send('Emulation.setDeviceMetricsOverride', {
width: 390,
height: 900,
deviceScaleFactor: 1,
mobile: true,
});
await evaluate("document.querySelector('#simulator-step-title').focus()");
const keyboardFocusSequence = [];
for (let index = 0; index < 12; index++) {
await send('Input.dispatchKeyEvent', {
type: 'keyDown',
key: 'Tab',
code: 'Tab',
windowsVirtualKeyCode: 9,
nativeVirtualKeyCode: 9,
});
await send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'Tab',
code: 'Tab',
windowsVirtualKeyCode: 9,
nativeVirtualKeyCode: 9,
});
keyboardFocusSequence.push(
await evaluate(`(() => {
const e = document.activeElement;
return {
tag: e?.tagName?.toLowerCase() ?? null,
name: e?.getAttribute?.('aria-label') || e?.innerText?.trim() || e?.labels?.[0]?.innerText?.trim() || e?.title || null,
visible: Boolean(e?.getClientRects?.().length),
focusVisible: Boolean(e?.matches?.(':focus-visible')),
};
})()`),
);
}
await send('Emulation.clearDeviceMetricsOverride');
const domAudit = await evaluate(`(() => {
const visible = e => e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden';
const controls = [...document.querySelectorAll('input:not([type="hidden"]), select, textarea')]
.filter(visible).map(e => ({
tag: e.tagName.toLowerCase(),
type: e.type ?? null,
name: e.name || null,
label: e.labels?.[0]?.innerText?.trim() || e.getAttribute('aria-label') ||
(e.getAttribute('aria-labelledby') ? document.getElementById(e.getAttribute('aria-labelledby'))?.innerText?.trim() : null),
}));
const buttons = [...document.querySelectorAll('button')].filter(visible).map(e => ({
text: e.innerText.trim(),
name: e.getAttribute('aria-label') || e.innerText.trim() || e.title || null,
}));
const links = [...document.querySelectorAll('a')].filter(visible).map(e => ({
text: e.innerText.trim(),
name: e.getAttribute('aria-label') || e.innerText.trim() || e.title || null,
href: e.getAttribute('href'),
}));
return { title: document.title, controls, buttons, links };
})()`);
const axTree = await send('Accessibility.getFullAXTree');
const controlRoles = new Set([
'button',
'textbox',
'searchbox',
'combobox',
'checkbox',
'radioButton',
'link',
'slider',
'spinbutton',
]);
const unnamedAxControls = axTree.nodes
.filter((node) => !node.ignored && controlRoles.has(node.role?.value))
.filter((node) => !node.name?.value?.trim())
.map((node) => node.role.value);
const unlabeledControls = domAudit.controls.filter(
(control) => !control.label,
);
const unnamedButtons = domAudit.buttons.filter((button) => !button.name);
const unnamedLinks = domAudit.links.filter((link) => !link.name);
const visibleAlerts =
await evaluate(`[...document.querySelectorAll('[role="alert"]')]
.filter(e => e.getClientRects().length > 0)
.map(e => e.innerText.trim())
.filter(Boolean)`);
const noOverflow = dimensions.every(
(item) =>
item.documentScroll <= item.document + 1 &&
item.bodyScroll <= item.document + 1,
);
const invalidKeyboardFocus = keyboardFocusSequence.filter(
(item) => !item.name || !item.visible,
);
const apiFailures = 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: 'UI, foco e GET público de rascunho; sem cálculo, escrita ou validação de conteúdo persistido',
title: domAudit.title,
dimensions,
keyboardFocusSequence,
invalidKeyboardFocus,
visibleControls: domAudit.controls.length,
visibleButtons: domAudit.buttons.length,
visibleLinks: domAudit.links.length,
unlabeledControls,
unnamedButtons,
unnamedLinks,
unnamedAxControls,
visibleAlerts,
javascriptExceptions: errors,
apiResponses,
apiFailures,
expectedQaTarget,
qaTargetVerified,
passed:
noOverflow &&
invalidKeyboardFocus.length === 0 &&
unlabeledControls.length === 0 &&
unnamedButtons.length === 0 &&
unnamedLinks.length === 0 &&
unnamedAxControls.length === 0 &&
visibleAlerts.length === 0 &&
apiResponses.length > 0 &&
qaTargetVerified &&
apiFailures.length === 0 &&
errors.length === 0,
};
fs.writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
console.log(JSON.stringify(report, null, 2));
if (!report.passed) process.exitCode = 1;
} finally {
ws.close();
await fetch(`http://127.0.0.1:9224/json/close/${target.id}`);
}