Files
Investidor-V2/tools/portal-header-qa.mjs

390 lines
15 KiB
JavaScript

// Isolated UI fixture: real header/providers, synthetic Inertia props, no database.
// Requires the local Vite server. No requests to Laravel or external services.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { portalNavigation } from '../resources/js/lib/portal-navigation.ts';
const root = process.cwd();
const dir = path.join(root, 'outputs/header-navbar-20261021');
fs.mkdirSync(dir, { recursive: true });
const require = createRequire(import.meta.url);
const { chromium } = require(process.env.HEADER_QA_PLAYWRIGHT || 'playwright');
const { expect } = require(
(process.env.HEADER_QA_PLAYWRIGHT || 'playwright') + '/test',
);
const base = process.env.HEADER_QA_VITE || 'http://127.0.0.1:5173';
assert.equal(new URL(base).hostname, '127.0.0.1');
const before = process.argv.includes('--before');
if (before && fs.existsSync(path.join(dir, 'before-results.json'))) {
throw new Error(
'The original baseline already exists and must be preserved.',
);
}
fs.writeFileSync(
path.join(dir, 'fixture.html'),
`<!doctype html>
<html lang="pt"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>InvesteMoz · revisão do header</title></head><body><div id="app"></div>
<script type="module" src="/outputs/header-navbar-20261021/fixture.tsx"></script></body></html>`,
);
fs.writeFileSync(
path.join(dir, 'fixture.tsx'),
`
import React from 'react';
import { createRoot } from 'react-dom/client';
import { createInertiaApp } from '@inertiajs/react';
import { PortalGroupedMenu } from '../../resources/js/components/portal-grouped-menu';
import { TempoUiProvider, useTempoUi } from '../../resources/js/components/tempo-brand';
import '../../resources/css/app.css';
const role = new URLSearchParams(location.search).get('role') || 'guest';
const props = {
auth: { user: role === 'guest' ? null : { id: 1, name: 'Investidor de demonstração com nome extenso', email: 'qa@example.invalid' }, canManagePortal: role === 'manager' },
existingBusinessEnabled: true, businessReviewUrl: role === 'manager' ? '/management/negocios' : null,
educationEnabled: true, aiEnabled: true, legacyContentEnabled: true,
};
function Fixture() {
const ui = useTempoUi();
return <><PortalGroupedMenu /><main style={{padding:'48px 24px',maxWidth:1200,margin:'auto'}}>
<p style={{color:'#b91c1c',fontSize:12,fontWeight:600,letterSpacing:2}}>INVESTEMOZ · REVISÃO LOCAL</p>
<h1 style={{fontSize:36,fontWeight:600,margin:'16px 0'}}>O seu próximo investimento começa aqui</h1>
<p style={{color:'#64748b'}}>Navegação real do portal com dados fictícios para testar a apresentação.</p>
<button data-test="increment" onClick={ui.registerSubmittedInterest}>Simular pedido no contador</button>
</main></>;
}
await createInertiaApp({page:{component:'fixture',props,url:'/',version:null},resolve:()=>Fixture,
setup({el,App,props}) {createRoot(el).render(<TempoUiProvider><App {...props}/></TempoUiProvider>);}});
`,
);
const browser = await chromium.launch({
headless: true,
...(process.env.HEADER_QA_CHROMIUM
? { executablePath: process.env.HEADER_QA_CHROMIUM }
: {}),
});
const results = [];
const errors = [];
try {
for (const [width, role, language] of before
? [
[1440, 'guest', 'pt'],
[390, 'guest', 'pt'],
]
: [
[1280, 'manager', 'fr'],
[1280, 'manager', 'pt'],
[1440, 'manager', 'fr'],
[320, 'guest', 'pt'],
[320, 'investor', 'pt'],
[390, 'guest', 'pt'],
[390, 'manager', 'pt'],
[768, 'investor', 'pt'],
[1024, 'manager', 'pt'],
[1440, 'guest', 'pt'],
[1920, 'investor', 'en'],
]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
});
await context.addInitScript(
(lang) => localStorage.setItem('tempo-navigation-language', lang),
language,
);
await context.route('**/*', (route) => {
const url = new URL(route.request().url());
if (url.origin === base && url.pathname.startsWith('/images/')) {
const asset = path.resolve(root, 'public', `.${url.pathname}`);
if (
asset.startsWith(
path.join(root, 'public', 'images') + path.sep,
) &&
fs.existsSync(asset)
) {
return route.fulfill({ path: asset });
}
}
if (url.origin === base || url.protocol === 'data:')
return route.continue();
return route.abort();
});
const page = await context.newPage();
page.on('pageerror', (e) => {
errors.push(e.message);
console.error(e.message);
});
await page.goto(
`${base}/outputs/header-navbar-20261021/fixture.html?role=${role}`,
{ waitUntil: 'domcontentloaded', timeout: 60000 },
);
const header = page.locator('.tempo-header');
await header.waitFor();
await page.screenshot({
path: path.join(
dir,
`${before ? 'before' : 'after'}-${width}-${role}-${language}.png`,
),
});
if (before) {
results.push({
width,
height: await header.evaluate(
(el) => el.getBoundingClientRect().height,
),
});
await context.close();
continue;
}
assert.equal(
await header.locator('nav').count(),
1,
'One navigation landmark in header',
);
assert.equal(await header.locator('.tempo-section-bar').count(), 0);
const collapsedHeight = await header.evaluate(
(el) => el.getBoundingClientRect().height,
);
assert.ok(collapsedHeight <= 80, `Header height ${collapsedHeight}`);
if (width < 1280) {
const toggle = header.locator('.tempo-header-toggle');
await toggle.click();
assert.equal(await toggle.getAttribute('aria-expanded'), 'true');
await page.screenshot({
path: path.join(
dir,
`expanded-${width}-${role}-${language}.png`,
),
});
await page.keyboard.press('Escape');
assert.equal(await toggle.getAttribute('aria-expanded'), 'false');
await expect(toggle).toBeFocused();
await toggle.click();
}
const nav = header.locator('nav');
if (width >= 1280) {
const boxes = await Promise.all(
[
'.tempo-logo-link',
'.tempo-primary-navigation',
'.tempo-header-extras',
'.tempo-header-actions',
'.tempo-interest-button',
].map((selector) => header.locator(selector).boundingBox()),
);
for (let i = 1; i < boxes.length; i++)
assert.ok(
boxes[i - 1].x + boxes[i - 1].width <= boxes[i].x + 1,
`Header elements overlap at ${width}px: ${JSON.stringify(boxes)}`,
);
}
assert.deepEqual(
await nav
.locator(':scope > ul > li')
.evaluateAll((nodes) =>
nodes.map(
(node) =>
node
.querySelector('a,button')
?.getAttribute('href') ||
node.textContent.trim(),
),
),
language === 'pt'
? [
'/',
'Descoberta',
'Realização',
'Suporte',
'/sobre',
'Tempo',
]
: language === 'fr'
? [
'/',
'Découvrir',
'Investir',
'Assistance',
'/sobre',
'Tempo',
]
: ['/', 'Discover', 'Invest', 'Support', '/sobre', 'Tempo'],
);
assert.equal(await nav.locator('a[href="/sobre"]').count(), 1);
assert.equal(
await nav
.locator('a')
.first()
.evaluate((el) => getComputedStyle(el).fontSize),
width >= 1280 ? '18px' : '16px',
);
const destinations = new Set(
await header
.locator('a[href]')
.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute('href')),
),
);
async function inspectMenu(menu) {
await menu.waitFor();
menu = page.locator(`[id="${await menu.getAttribute('id')}"]`);
try {
await expect
.poll(async () => {
const box = await menu.boundingBox();
return (
!!box &&
box.x >= 0 &&
box.x + box.width <= width + 1
);
})
.toBe(true);
} catch (error) {
console.error(
JSON.stringify({
width,
role,
menu: await menu.getAttribute('aria-labelledby'),
box: await menu.boundingBox(),
}),
);
await page.screenshot({
path: path.join(dir, 'submenu-failure.png'),
});
throw error;
}
for (const href of await menu
.locator('a[href]')
.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute('href')),
))
destinations.add(href);
const subIds = await menu
.locator('[role="menuitem"][aria-haspopup="menu"]')
.evaluateAll((nodes) => nodes.map((node) => node.id));
for (const subId of subIds) {
const sub = page.locator(`[id="${subId}"]`);
await sub.focus();
await page.keyboard.press('ArrowRight');
await expect(sub).toHaveAttribute('aria-expanded', 'true');
await inspectMenu(page.locator('[role="menu"]').last());
await page.keyboard.press('ArrowLeft');
await expect(sub).toBeFocused();
}
}
for (const trigger of await nav
.locator(':scope > ul > li > button')
.all()) {
await trigger.focus();
await page.keyboard.press('Enter');
assert.equal(await trigger.getAttribute('aria-expanded'), 'true');
const menu = page.locator('[role="menu"]').last();
await inspectMenu(menu);
await page.keyboard.press('Escape');
await expect(trigger).toBeFocused();
}
await header
.getByRole('button', { name: 'Tempo', exact: true })
.click();
for (const href of [
'/contacto',
'https://revista.tempo.co.mz/',
'https://www.facebook.com/revistatempo.mz/',
'https://www.instagram.com/revistatempo.mz/',
'https://www.linkedin.com/company/revistatempo/',
'https://www.youtube.com/@RevistaTempoMz/',
]) {
assert.equal(
await page.locator(`[role="menu"] a[href="${href}"]`).count(),
1,
href,
);
}
await page.keyboard.press('Escape');
assert.ok(
await header
.getByRole('button', { name: 'Idioma da navegação' })
.isVisible(),
);
assert.ok(
await header
.getByRole('button', { name: 'Temperatura em Maputo' })
.isVisible(),
);
assert.ok(
await header.locator('a[href="/manifestar-interesse"]').isVisible(),
);
const dashboard = header.locator('a[href="/investidor/manifestacoes"]');
assert.ok(await dashboard.isVisible());
await page.locator('[data-test="increment"]').click();
assert.match(await dashboard.getAttribute('aria-label'), /1 /);
if (role !== 'guest') {
await header.locator('.tempo-login-button').click();
await inspectMenu(page.locator('[role="menu"]').last());
assert.ok(
await page
.locator('[data-test="portal-logout-button"]')
.isVisible(),
);
const hasManagement = await page
.getByRole('menu')
.getByText('Gestão', { exact: true })
.count();
assert.equal(hasManagement > 0, role === 'manager');
await page.keyboard.press('Escape');
} else {
assert.ok(await header.locator('a[href="/login"]').isVisible());
}
const catalog = portalNavigation({
authenticated: role !== 'guest',
manager: role === 'manager',
businesses: true,
businessReviewUrl:
role === 'manager' ? '/management/negocios' : null,
education: true,
ai: true,
legacyContent: true,
});
function checkDestinations(groups) {
for (const group of groups) {
if (group.children) checkDestinations(group.children);
else if (group.href)
assert.ok(
destinations.has(group.href),
`Preserved destination: ${group.href}`,
);
}
}
checkDestinations(
role === 'guest'
? catalog.filter((group) => group.id !== 'account')
: catalog,
);
const overflow = await header.evaluate((el) =>
Array.from(el.querySelectorAll('a,button,img'))
.filter((n) => {
const r = n.getBoundingClientRect();
return r.width && (r.x < -1 || r.right > innerWidth + 1);
})
.map((n) => n.textContent),
);
assert.deepEqual(overflow, []);
results.push({
width,
role,
language,
collapsedHeight,
destinations: destinations.size,
result: 'passed',
});
await context.close();
}
assert.deepEqual(errors, []);
fs.writeFileSync(
path.join(dir, before ? 'before-results.json' : 'qa-results.json'),
JSON.stringify({ results, errors }, null, 2),
);
console.log(JSON.stringify({ results, errors }));
} finally {
await browser.close();
}