Files
Investidor-V2/tools/portal-visual-qa-lib.cjs
nala 287e991486
tests / ci (push) Waiting to run
oportunidades 7
2026-10-13 11:40:29 +02:00

299 lines
10 KiB
JavaScript

// Browser QA helpers: local credentials are read only; receipts omit cookies and auth bodies.
const fs = require('node:fs'),
path = require('node:path'),
assert = require('node:assert/strict'),
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, '..'),
DIR = path.join(ROOT, 'storage/app/private/investmoz-v2/portal-audit');
const BASE = 'http://127.0.0.1:18097',
TARGET = 'desk_restore_20261009_174726_test:55441';
const actors = JSON.parse(
fs.readFileSync(path.join(DIR, 'profiles-v1-actors-private.json'), 'utf8'),
);
actors.investor18 = JSON.parse(
fs.readFileSync(path.join(DIR, 'investor-mfa-qa-private.json'), 'utf8'),
);
actors.manager = JSON.parse(
fs.readFileSync(
path.join(DIR, 'resource-http-actors-private.json'),
'utf8',
),
).manager;
actors.geo = JSON.parse(
fs.readFileSync(path.join(DIR, 'i0-qa-actor-private.json'), 'utf8'),
);
if (fs.existsSync(path.join(DIR, 'revision-geo-actor-qa.json')))
actors.geo.actor_id = JSON.parse(
fs.readFileSync(path.join(DIR, 'revision-geo-actor-qa.json'), 'utf8'),
).actor_id;
function otp(secret) {
let bits = 0,
value = 0;
const bytes = [],
alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
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(),
offset = digest[19] & 15;
return String(
(digest.readUInt32BE(offset) & 0x7fffffff) % 1000000,
).padStart(6, '0');
}
function sanitized(error) {
let message = String(error.message || error);
for (const a of Object.values(actors))
for (const k of ['password', 'totp_secret', 'secret'])
if (a[k]) message = message.replaceAll(a[k], '[REDACTED]');
return message
.replace(
/^.*(?:cookie:|x-xsrf-token:|authorization:).*$/gim,
'[AUTH HEADER OMITTED]',
)
.replace(/\b\d{6}\b/g, '[OTP OMITIDO]')
.slice(0, 1800);
}
async function start(name) {
const receipt = path.join(DIR, name + '.json'),
stamp = new Date().toISOString().replace(/[:.]/g, '-');
const prior = fs.existsSync(receipt)
? JSON.parse(fs.readFileSync(receipt, 'utf8'))
: {};
if (fs.existsSync(receipt))
fs.copyFileSync(
receipt,
path.join(DIR, name + '-attempt-' + stamp + '.json'),
);
const proof = {
started_at: new Date().toISOString(),
base: BASE,
target: TARGET,
browser: 'Chromium 1243, isolated contexts',
active_writes: 0,
checks: {},
events: [],
screenshots: [],
};
const shots = path.join(DIR, name + '-' + stamp);
fs.mkdirSync(shots, { recursive: true });
const browser = await chromium.launch({
headless: true,
executablePath:
'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe',
});
const assets = new Map(); // Only immutable public QA build assets; never cache authenticated pages/API.
function save() {
fs.writeFileSync(receipt, JSON.stringify(proof, null, 2));
}
function phase(value) {
proof.phase = value;
save();
console.log(JSON.stringify({ phase: value }));
}
async function context(role, width = 1440) {
const ctx = await browser.newContext({
viewport: { width, height: 1000 },
locale: 'pt-MZ',
reducedMotion: 'reduce',
});
ctx.qaConfirmed = false;
await ctx.route('**/*', async (route) => {
try {
const req = route.request(),
u = new URL(req.url());
if (u.origin !== BASE) return route.abort();
if (!['GET', 'HEAD'].includes(req.method()))
assert(
ctx.qaConfirmed,
'QA target must be confirmed before mutation',
);
if (
req.method() === 'GET' &&
u.pathname.startsWith('/build-qa-opportunities/assets/')
) {
if (!assets.has(u.href)) {
const r = await route.fetch();
assets.set(u.href, {
status: r.status(),
headers: r.headers(),
body: await r.body(),
});
}
return route.fulfill(assets.get(u.href));
}
return await route.continue();
} catch (error) {
if (ctx.qaClosing || page.isClosed()) return;
(proof.harness_errors ||= []).push(sanitized(error));
save();
try {
await route.abort();
} catch {}
}
});
ctx.on('response', (r) => {
const u = new URL(r.url());
if (u.origin === BASE && !u.pathname.startsWith('/build-qa-')) {
const h = r.headers();
proof.events.push({
actor: role,
method: r.request().method(),
path: u.pathname,
status: r.status(),
target: h['x-investmoz-qa-target'] || null,
redirect: h.location
? new URL(h.location, BASE).pathname
: null,
});
save();
}
});
const page = await ctx.newPage();
ctx.on('close', () => {
ctx.qaClosing = true;
});
page.setDefaultTimeout(25000);
page.setDefaultNavigationTimeout(45000);
page.on('pageerror', (e) => {
(proof.javascript_errors ||= []).push(sanitized(e));
save();
});
return { ctx, page };
}
async function open(page, url) {
const r = await page.goto(BASE + url, {
waitUntil: 'domcontentloaded',
});
assert.equal(r.headers()['x-investmoz-qa-target'], TARGET);
page.context().qaConfirmed = true;
return r;
}
async function login(page, role) {
const a = actors[role];
assert(a, 'Known local QA actor');
await open(page, '/login');
await page.locator('#email').fill(a.email);
await page.locator('#password').fill(a.password);
await page.locator('[data-test="login-button"]').click();
await page.waitForURL((u) => u.pathname !== '/login');
if (new URL(page.url()).pathname === '/two-factor-challenge') {
await page.locator('input[name="code"]').fill(otp(a.totp_secret));
await page
.getByRole('button', { name: 'Continue', exact: true })
.click();
await page.waitForURL(
(u) =>
!['/login', '/two-factor-challenge'].includes(u.pathname),
);
}
await page.locator('main').first().waitFor();
const response = await open(page, '/dashboard');
const html = await response.text();
const encoded = html.match(/data-page="([^"]+)"/);
let body;
if (encoded && encoded[1].startsWith('{')) {
body = JSON.parse(
encoded[1]
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&#039;/g, "'"),
);
} else {
const script = html.match(
/<script[^>]*type="application\/json"[^>]*>([\s\S]*?)<\/script>/,
);
assert(
script,
'Inertia bootstrap must identify authenticated actor',
);
body = JSON.parse(script[1]);
}
const actual = body.props.auth.user.id;
assert.equal(actual, a.id || a.actor_id);
(proof.authenticated_actors ||= []).push({ role, id: actual });
save();
}
async function logout(page) {
await open(page, '/dashboard');
if (page.viewportSize().width < 768) {
const toggle = page.getByRole('button', {
name: /Toggle sidebar/i,
});
if (await toggle.isVisible()) await toggle.click();
}
const buttons = page.locator('button[aria-haspopup="menu"]');
for (let i = (await buttons.count()) - 1; i >= 0; i--) {
if (!(await buttons.nth(i).isVisible())) continue;
await buttons.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 Error('Visible logout missing');
}
async function shot(page, name, fullPage = true) {
assert(
!['/login', '/two-factor-challenge'].includes(
new URL(page.url()).pathname,
),
'No authentication screenshots',
);
const file = path.join(shots, name + '.png');
await page.screenshot({ path: file, fullPage });
proof.screenshots.push(path.relative(ROOT, file).replaceAll('\\', '/'));
save();
}
async function json(page, url) {
const r = await page.request.get(BASE + url, {
headers: { Accept: 'application/json' },
});
assert.equal(r.headers()['x-investmoz-qa-target'], TARGET);
return { status: r.status(), data: await r.json() };
}
return {
proof,
prior,
save,
phase,
browser,
context,
open,
login,
logout,
shot,
json,
stamp,
receipt,
};
}
module.exports = {
start,
BASE,
TARGET,
DIR,
actors,
assert,
fs,
path,
sanitized,
};