265 lines
18 KiB
JavaScript
265 lines
18 KiB
JavaScript
/** Real Chromium render/click acceptance on the existing, isolated QA server. */
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
const { chromium } = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const PRIVATE = path.join(ROOT, 'storage/app/private/investmoz-v2/portal-audit');
|
|
const BASE = 'http://127.0.0.1:18097';
|
|
const TARGET = 'desk_restore_20261009_174726_test:55441';
|
|
const receiptPath = path.join(PRIVATE, 'legacy-content-browser-qa.json');
|
|
const actors = JSON.parse(fs.readFileSync(path.join(PRIVATE, 'profiles-v1-actors-private.json'), 'utf8'));
|
|
assert.equal(actors.promoter.id, 21, 'Use the designated synthetic Promotor QA21');
|
|
const previous = fs.existsSync(receiptPath) ? JSON.parse(fs.readFileSync(receiptPath, 'utf8')) : {};
|
|
if (previous.started_at) {
|
|
const archive = path.join(PRIVATE, 'legacy-browser-attempt-' + previous.started_at.replace(/[^0-9TZ-]/g, '-') + '.json');
|
|
if (!fs.existsSync(archive)) fs.copyFileSync(receiptPath, archive);
|
|
}
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const screenshotDir = path.join(PRIVATE, 'legacy-browser-' + stamp);
|
|
fs.mkdirSync(screenshotDir, { recursive: true });
|
|
const proof = { started_at: new Date().toISOString(), base: BASE, target: TARGET,
|
|
browser: 'installed Chromium 1243, isolated headless contexts', native_control: 'pipe unavailable; two probes',
|
|
interface_observed: true, active_writes: 0, checks: {}, events: [], screenshots: [],
|
|
dossier_id: previous.dossier_id || null, continuation_id: previous.continuation_id || null,
|
|
title: previous.title || 'QA visual — ideia de agronegócio ' + stamp, last_step: 'initialization' };
|
|
let browser;
|
|
function save() { fs.writeFileSync(receiptPath, JSON.stringify(proof, null, 2)); }
|
|
function step(name) { proof.last_step = name; save(); console.log(JSON.stringify({ step: name })); }
|
|
function totp(secret) {
|
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
let bits = 0, value = 0; const bytes = [];
|
|
for (const char of secret.toUpperCase().replace(/=+$/, '')) {
|
|
const digit = alphabet.indexOf(char); assert(digit >= 0);
|
|
value = (value << 5) | digit; bits += 5;
|
|
if (bits >= 8) { bytes.push((value >>> (bits - 8)) & 255); bits -= 8; }
|
|
}
|
|
const counter = Buffer.alloc(8); counter.writeBigUInt64BE(BigInt(Math.floor(Date.now() / 30000)));
|
|
const digest = crypto.createHmac('sha1', Buffer.from(bytes)).update(counter).digest();
|
|
const offset = digest[19] & 15;
|
|
return String((digest.readUInt32BE(offset) & 0x7fffffff) % 1000000).padStart(6, '0');
|
|
}
|
|
async function context(viewport = { width: 1440, height: 1000 }) {
|
|
const ctx = await browser.newContext({ viewport, locale: 'pt-MZ', reducedMotion: 'reduce' });
|
|
ctx.qaConfirmed = false;
|
|
await ctx.route('**/*', async (route) => {
|
|
const request = route.request(), url = new URL(request.url());
|
|
if (url.origin !== BASE) { proof.events.push({ external_request_blocked: url.origin }); return route.abort(); }
|
|
if (!['GET', 'HEAD'].includes(request.method())) assert(ctx.qaConfirmed, 'Confirm actual QA before any write');
|
|
await route.continue();
|
|
});
|
|
ctx.on('response', (response) => {
|
|
const request = response.request(), url = new URL(response.url());
|
|
if (url.origin === BASE && !url.pathname.startsWith('/build-qa-')) {
|
|
const headers = response.headers();
|
|
proof.events.push({ method: request.method(), path: url.pathname, status: response.status(),
|
|
target: headers['x-investmoz-qa-target'] || null,
|
|
redirect_path: headers.location ? new URL(headers.location, BASE).pathname : null });
|
|
}
|
|
});
|
|
return ctx;
|
|
}
|
|
async function open(page, route) {
|
|
const response = await page.goto(BASE + route, { waitUntil: 'networkidle' });
|
|
assert.equal(response.headers()['x-investmoz-qa-target'], TARGET);
|
|
page.context().qaConfirmed = true;
|
|
return response;
|
|
}
|
|
async function screenshot(page, name, fullPage = true) {
|
|
const file = path.join(screenshotDir, name + '.png');
|
|
await page.screenshot({ path: file, fullPage });
|
|
proof.screenshots.push({ name, path: path.relative(ROOT, file).replaceAll('\\', '/') });
|
|
}
|
|
async function login(page, role) {
|
|
const account = actors[role];
|
|
if (!page.url().startsWith(BASE + '/login')) await open(page, '/login');
|
|
await page.locator('#email').fill(account.email);
|
|
await page.locator('#password').fill(account.password);
|
|
await page.locator('[data-test="login-button"]').click();
|
|
await page.waitForURL((url) => url.pathname === '/two-factor-challenge', { timeout: 15000 });
|
|
assert(account.totp_secret, 'Normal MFA is required; never bypass it');
|
|
await page.locator('input[name="code"]').fill(totp(account.totp_secret));
|
|
await page.getByRole('button', { name: 'Continue', exact: true }).click();
|
|
await page.waitForURL((url) => !['/login', '/two-factor-challenge'].includes(url.pathname), { timeout: 15000 });
|
|
await page.waitForLoadState('networkidle');
|
|
const cookies = await page.context().cookies(BASE);
|
|
proof.checks['mfa_' + role] = { actor_id: account.id, final_path: new URL(page.url()).pathname,
|
|
cookies: cookies.map(({ name, domain, path, secure, sameSite, httpOnly }) => ({ name, domain, path, secure, sameSite, httpOnly })) };
|
|
}
|
|
async function logout(page) {
|
|
await open(page, '/dashboard');
|
|
const triggers = page.locator('button[aria-haspopup="menu"]');
|
|
const count = await triggers.count();
|
|
for (let i = count - 1; i >= 0; i--) {
|
|
await triggers.nth(i).click();
|
|
const action = page.locator('[data-test="logout-button"]');
|
|
if (await action.isVisible()) { await action.click(); await page.waitForURL(BASE + '/'); return; }
|
|
await page.keyboard.press('Escape');
|
|
}
|
|
throw new Error('Visible logout action missing on dashboard');
|
|
}
|
|
async function assertDossier(page, id) {
|
|
await page.waitForURL(BASE + '/investimentos/' + id);
|
|
assert.equal(new URL(page.url()).pathname, '/investimentos/' + id);
|
|
await page.getByRole('heading', { name: proof.title, exact: true }).waitFor();
|
|
assert.equal(await page.getByRole('heading', { name: /^Fase \d+ ·/ }).count(), 5);
|
|
assert.equal(await page.locator('article[id^="S"]').count(), 19);
|
|
assert.equal(await page.getByRole('region', { name: 'Fontes da ideia' }).locator('li').count(), 1);
|
|
await page.getByRole('heading', { name: 'Próxima acção', exact: true }).waitFor();
|
|
}
|
|
|
|
(async () => {
|
|
browser = await chromium.launch({ headless: true,
|
|
executablePath: 'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe' });
|
|
step('public desktop rendering');
|
|
const anonymous = await context(), page = await anonymous.newPage();
|
|
page.setDefaultTimeout(15000);
|
|
const first = await open(page, '/explorar/agronegocio'); assert.equal(first.status(), 200);
|
|
assert.equal(await page.title(), 'Explorar agronegócio - InvesteMoz QA');
|
|
proof.checks.qa_browser_title = true;
|
|
await page.getByRole('heading', { name: 'Investir no agronegócio', exact: true }).waitFor();
|
|
await page.locator('main img').waitFor();
|
|
await page.evaluate(() => document.fonts.ready);
|
|
proof.checks.desktop = await page.locator('main').evaluate((main) => ({
|
|
background: getComputedStyle(main).backgroundColor, text: getComputedStyle(main).color,
|
|
font: getComputedStyle(main).fontFamily, headingFont: getComputedStyle(main.querySelector('h1')).fontFamily,
|
|
notoSansLoaded: document.fonts.check('16px "Noto Sans"'), notoSerifLoaded: document.fonts.check('600 36px "Noto Serif"'),
|
|
photoLoaded: main.querySelector('img').complete && main.querySelector('img').naturalWidth === 900,
|
|
horizontalOverflow: document.documentElement.scrollWidth > innerWidth,
|
|
}));
|
|
assert(proof.checks.desktop.photoLoaded); assert(!proof.checks.desktop.horizontalOverflow);
|
|
await screenshot(page, 'public-landing-desktop');
|
|
step('visible search hit and miss');
|
|
await page.getByRole('searchbox').fill('corredores');
|
|
await page.getByRole('button', { name: 'Pesquisar', exact: true }).click();
|
|
await page.waitForURL(/q=corredores/); await page.getByRole('link', { name: 'Ler o artigo', exact: true }).waitFor();
|
|
await page.getByRole('searchbox').fill('zzzz-no-content-qa');
|
|
await page.getByRole('button', { name: 'Pesquisar', exact: true }).click();
|
|
await page.getByText('Nenhum conteúdo corresponde à pesquisa.', { exact: true }).waitFor();
|
|
assert.equal(await page.getByRole('link', { name: 'Ler o artigo', exact: true }).count(), 0);
|
|
await screenshot(page, 'public-search-no-result');
|
|
await page.getByRole('searchbox').fill('');
|
|
await page.getByRole('button', { name: 'Pesquisar', exact: true }).click();
|
|
await page.getByRole('link', { name: 'Ler o artigo', exact: true }).waitFor();
|
|
proof.checks.search_hit_miss_clear = true;
|
|
step('keyboard article navigation and focus');
|
|
const read = page.getByRole('link', { name: 'Ler o artigo', exact: true }); await read.focus();
|
|
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
|
|
proof.checks.focus = await read.evaluate((el) => ({ focused: el === document.activeElement,
|
|
outlineStyle: getComputedStyle(el).outlineStyle, outlineWidth: getComputedStyle(el).outlineWidth }));
|
|
await page.keyboard.press('Enter'); await page.waitForURL(BASE + '/agronegocio');
|
|
const article = page.getByRole('region', { name: 'Artigo educativo' }); await article.waitFor();
|
|
assert((await article.innerText()).length > 500);
|
|
await screenshot(page, 'public-article-desktop');
|
|
proof.checks.public_article = { characters_rendered: (await article.innerText()).length, source_visible: true };
|
|
step('consumer links through clicks');
|
|
proof.checks.consumer_links = [];
|
|
for (const name of ['Consultar os corredores cadastrados', 'Consultar localizações e mapas disponíveis', 'Exemplar na biblioteca']) {
|
|
const consumer = page.getByRole('link', { name, exact: true });
|
|
const destination = new URL(await consumer.getAttribute('href'), BASE);
|
|
await consumer.click(); await page.waitForURL(destination.href); await page.waitForLoadState('networkidle');
|
|
proof.checks.consumer_links.push({ name, path: new URL(page.url()).pathname,
|
|
rendered_heading: (await page.locator('h1').allTextContents()).filter(Boolean).length > 0 });
|
|
await page.goBack({ waitUntil: 'networkidle' }); await page.waitForURL(BASE + '/agronegocio'); await article.waitFor();
|
|
}
|
|
const opportunity = page.locator('section[aria-labelledby="opportunities-title"] a').first();
|
|
if (await opportunity.count()) {
|
|
const destination = new URL(await opportunity.getAttribute('href'), BASE);
|
|
await opportunity.click(); await page.waitForURL(destination.href); await page.waitForLoadState('networkidle');
|
|
assert(new URL(page.url()).pathname.startsWith('/oportunidades/'));
|
|
proof.checks.published_opportunity_clicked = new URL(page.url()).pathname;
|
|
await page.goBack({ waitUntil: 'networkidle' }); await article.waitFor();
|
|
}
|
|
step('mobile public rendering');
|
|
const mobileCtx = await context({ width: 390, height: 844 }), mobile = await mobileCtx.newPage();
|
|
await open(mobile, '/explorar/agronegocio'); await mobile.getByRole('heading', { name: 'Investir no agronegócio', exact: true }).waitFor();
|
|
proof.checks.mobile = await mobile.evaluate(() => ({ width: innerWidth, contentWidth: document.documentElement.scrollWidth,
|
|
photoLoaded: !!document.querySelector('main img')?.naturalWidth }));
|
|
assert(proof.checks.mobile.contentWidth <= proof.checks.mobile.width);
|
|
await screenshot(mobile, 'public-landing-mobile');
|
|
await mobile.getByRole('link', { name: 'Ler o artigo', exact: true }).click(); await mobile.getByRole('region', { name: 'Artigo educativo' }).waitFor();
|
|
assert(await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
|
await screenshot(mobile, 'public-article-mobile'); await mobileCtx.close();
|
|
step('public CTA, normal Promotor QA21 login and MFA');
|
|
await page.getByRole('link', { name: 'Iniciar ou continuar uma ideia', exact: true }).click();
|
|
await page.waitForURL(BASE + '/login'); await login(page, 'promoter');
|
|
assert.equal(new URL(page.url()).pathname, '/investimentos/a-partir-de/agronegocio');
|
|
await page.getByRole('heading', { name: 'Os meus investimentos', exact: true }).waitFor();
|
|
step('create and save dossier using UI');
|
|
const createdThisRun = !proof.dossier_id;
|
|
if (createdThisRun) {
|
|
await page.getByLabel('Título', { exact: true }).fill(proof.title);
|
|
await page.getByLabel('Autorizo reutilizar o meu perfil neste dossier privado.').check();
|
|
await page.getByRole('button', { name: 'Criar e guardar dossier', exact: true }).click();
|
|
await page.waitForURL(/\/investimentos\/[0-9a-f-]{36}$/);
|
|
proof.dossier_id = new URL(page.url()).pathname.split('/').pop(); save();
|
|
} else await page.getByRole('link', { name: proof.title, exact: true }).click();
|
|
await assertDossier(page, proof.dossier_id); await screenshot(page, 'private-dossier-created', false);
|
|
proof.checks.created_from_ui = true;
|
|
proof.checks.created_this_run = createdThisRun;
|
|
if (!createdThisRun) {
|
|
// Creation may be an earlier observed action, not another insert on every rerun.
|
|
const files = fs.readdirSync(PRIVATE).filter(name => name.startsWith('legacy-browser-attempt-') && name.endsWith('.json'));
|
|
const creation = files.map(name => ({ name, receipt: JSON.parse(fs.readFileSync(path.join(PRIVATE, name), 'utf8')) }))
|
|
.find(({ receipt }) => receipt.dossier_id === proof.dossier_id
|
|
&& receipt.events?.some(event => event.method === 'POST' && event.path === '/investimentos' && event.status === 302));
|
|
assert(creation, 'Existing dossier must have retained evidence of UI creation');
|
|
proof.creation_evidence = { receipt: creation.name, started_at: creation.receipt.started_at,
|
|
action: 'POST /investimentos through UI; subsequent retries recover this same dossier',
|
|
whole_attempt_result: creation.receipt.result };
|
|
}
|
|
step('source return and same dossier context');
|
|
await page.getByRole('region', { name: 'Fontes da ideia' }).locator('a').first().click(); await article.waitFor();
|
|
await page.getByRole('link', { name: 'Continuar: ' + proof.title, exact: true }).click();
|
|
await assertDossier(page, proof.dossier_id); proof.checks.private_return_context = true;
|
|
step('visible logout and new browser session');
|
|
await logout(page); await anonymous.close();
|
|
const freshCtx = await context(), fresh = await freshCtx.newPage();
|
|
await open(fresh, '/investimentos/' + proof.dossier_id); assert.equal(new URL(fresh.url()).pathname, '/login');
|
|
await login(fresh, 'promoter'); await assertDossier(fresh, proof.dossier_id);
|
|
await fresh.reload({ waitUntil: 'networkidle' }); await assertDossier(fresh, proof.dossier_id);
|
|
await screenshot(fresh, 'private-dossier-new-session', false); proof.checks.logout_new_session_reload = true;
|
|
step('continue existing dossier and repeat without duplicate');
|
|
await open(fresh, '/investimentos/a-partir-de/agronegocio');
|
|
const card = fresh.getByRole('listitem').filter({ has: fresh.getByRole('link', { name: proof.title, exact: true }) });
|
|
await card.getByRole('button', { name: 'Continuar neste dossier com o artigo', exact: true }).click();
|
|
await assertDossier(fresh, proof.dossier_id);
|
|
proof.checks.existing_dossier_replay_single_visible_reference = true;
|
|
step('anonymous context does not expose private dossier');
|
|
const publicCtx = await context(), publicPage = await publicCtx.newPage();
|
|
await open(publicPage, '/agronegocio?context=' + proof.dossier_id); await publicPage.getByRole('region', { name: 'Artigo educativo' }).waitFor();
|
|
assert.equal(await publicPage.getByText(proof.title, { exact: false }).count(), 0);
|
|
await publicPage.getByRole('link', { name: 'Iniciar ou continuar uma ideia', exact: true }).waitFor();
|
|
proof.checks.anonymous_private_context_hidden = true; await publicCtx.close();
|
|
step('other synthetic account denied and public source retained');
|
|
const otherCtx = await context(), other = await otherCtx.newPage();
|
|
await open(other, '/login'); await login(other, 'outsider');
|
|
const denied = await open(other, '/investimentos/' + proof.dossier_id); assert.equal(denied.status(), 403);
|
|
await open(other, '/agronegocio?context=' + proof.dossier_id); await other.getByRole('region', { name: 'Artigo educativo' }).waitFor();
|
|
assert.equal(await other.getByText(proof.title, { exact: false }).count(), 0);
|
|
proof.checks.other_account_denied = { actor_id: actors.outsider.id, dossier_status: 403, public_article_visible: true };
|
|
await otherCtx.close(); await logout(fresh); await freshCtx.close();
|
|
proof.result = 'passed'; proof.completed_at = new Date().toISOString(); save();
|
|
console.log(JSON.stringify({ result: proof.result, actor_id: 21, dossier_id: proof.dossier_id,
|
|
interface_observed: true, checks: Object.keys(proof.checks), screenshots: proof.screenshots.length, active_writes: 0 }));
|
|
})().catch(async (error) => {
|
|
let message = error.message;
|
|
for (const account of Object.values(actors)) {
|
|
for (const key of ['password', 'totp_secret', 'secret']) {
|
|
if (typeof account[key] === 'string' && account[key]) message = message.replaceAll(account[key], '[REDACTED]');
|
|
}
|
|
}
|
|
message = message.replace(/\b\d{6}\b/g, '[MFA OMITIDO]');
|
|
proof.result = 'failed'; proof.failure = { step: proof.last_step, type: error.name,
|
|
message: message.slice(0, 2500), pages: [] };
|
|
if (browser) for (const ctx of browser.contexts()) for (const page of ctx.pages()) {
|
|
proof.failure.pages.push({ path: new URL(page.url()).pathname,
|
|
headings: await page.locator('h1').allTextContents().catch(() => []),
|
|
error_count: await page.getByRole('alert').count().catch(() => 0) });
|
|
}
|
|
save();
|
|
console.error(JSON.stringify({ step: proof.failure.step, type: proof.failure.type, message: 'Sanitized diagnostic saved privately' })); process.exitCode = 1;
|
|
}).finally(async () => { if (browser) await browser.close(); });
|