580 lines
21 KiB
JavaScript
580 lines
21 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();
|
|
});
|