265 lines
10 KiB
JavaScript
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}`);
|
|
}
|