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

824 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const {
start,
BASE,
DIR,
actors: _actors,
assert,
fs,
path,
sanitized,
} = require('./portal-visual-qa-lib.cjs');
void (async () => {
const q = await start('consumer-workflow-chromium-20261010');
q.proof.workflow = q.prior.workflow || {
title: 'QA visual workflow — revisão independente',
steps: [],
};
const w = q.proof.workflow;
let page, ctx, props;
async function open(route) {
const r = await q.open(page, route),
html = await r.text();
const a = html.match(/data-page="([^"]+)"/);
const raw =
a && a[1].startsWith('{')
? a[1]
.replace(/"/g, '"')
.replace(/&/g, '&')
.replace(/'/g, "'")
: html.match(
/<script[^>]*type="application\/json"[^>]*>([\s\S]*?)<\/script>/,
)?.[1];
assert(raw, 'Authorized Inertia page');
props = JSON.parse(raw).props;
await page.getByRole('heading').first().waitFor();
return props;
}
async function actor(role) {
if (ctx) await ctx.close();
({ page, ctx } = await q.context(role));
await q.login(page, role);
}
const source = fs.readFileSync(
path.join(
__dirname,
'../resources/js/components/opportunity-canonical-fields.tsx',
),
'utf8',
);
function label(key) {
return (
source.match(
new RegExp('^\\s*' + key + ':\\s*"([^"]+)"', 'm'),
)?.[1] || key.replaceAll('_', ' ')
);
}
async function scalar(scope, key, field, value) {
let input = scope.locator('[id=' + JSON.stringify(field) + ']');
if (!(await input.count()))
input = scope.getByLabel(
new RegExp(
'^' + label(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
),
);
assert.equal(await input.count(), 1, 'One UI field ' + field);
const tag = await input.evaluate((n) => n.tagName);
if (tag === 'SELECT') await input.selectOption(String(value));
else await input.fill(String(value));
}
async function fill(scope, key, field, schema, value, options) {
if (value === null || value === undefined) return;
if (schema?.$ref)
schema = options.definitions[schema.$ref.split('/').pop()];
if (Array.isArray(value)) {
if (!value.length) return;
const box = scope
.locator(
'fieldset:has(> legend:text-is(' +
JSON.stringify(label(key)) +
'))',
)
.first();
assert.equal(await box.count(), 1, 'Array editor ' + field);
for (let i = 0; i < value.length; i++) {
const row = box.locator(':scope > div.space-y-2').nth(i);
if (!(await row.count()))
await box.locator(':scope > button').click();
await fill(
row,
key,
field + '.' + i,
schema.items,
value[i],
options,
);
}
return;
}
if (typeof value === 'object') {
for (const [k, v] of Object.entries(value))
if (schema?.properties?.[k])
await fill(
scope,
k,
field + '.' + k,
schema.properties[k],
v,
options,
);
return;
}
await scalar(scope, key, field, value);
}
async function transition(action, extra = {}) {
q.phase(
'Visual workflow ' + action + (extra.gate ? ' ' + extra.gate : ''),
);
await open('/minhas-oportunidades/' + w.id + '/view');
const before = props.workflowState,
box = page.getByRole('region', {
name: 'Workflow interno',
exact: true,
});
assert(
props.profileActions.includes(action),
'Action available ' + action,
);
await box
.getByLabel('Fundamentação', { exact: true })
.fill(
'Ensaio visual sintético QA: revisão independente, sem decisão externa.',
);
if (extra.correction)
await box
.getByLabel('Campos ou documentos a corrigir, um por linha', {
exact: true,
})
.fill(extra.correction);
if (extra.gate) {
await box.getByLabel(/^Revisão/).selectOption(extra.gate);
await box.getByLabel(/^Decisão/).selectOption('approved');
await box
.getByLabel('QA comprovativo do workflow visual', {
exact: true,
})
.check();
}
if (action === 'publish')
await box.getByLabel(/Confirmei a pré-visualização/).check();
const names = {
submit: 'Submeter para validação',
request_correction: 'Solicitar correções',
start_review: 'Iniciar validação',
review_gate: 'Guardar revisão especializada',
approve: 'Aprovar versão',
publish: 'Publicar no catálogo público',
};
const response = page.waitForResponse(
(r) =>
r.request().method() === 'POST' &&
new URL(r.url()).pathname ===
'/minhas-oportunidades/' + w.id + '/workflow',
);
await box
.getByRole('button', { name: names[action], exact: true })
.click();
const r = await response;
assert.equal(r.status(), 200, 'Workflow ' + action);
await page.waitForLoadState('networkidle');
await open('/minhas-oportunidades/' + w.id + '/view');
w.steps.push({
actor: props.auth.user.id,
action,
before,
after: props.workflowState,
gate: extra.gate,
});
q.save();
await q.shot(
page,
action + (extra.gate ? '-' + extra.gate : ''),
false,
);
}
try {
q.phase('QA21 dashboard creates incomplete recoverable opportunity');
await actor('promoter');
await open('/dashboard');
await page
.getByRole('link', { name: 'Nova oportunidade', exact: true })
.first()
.click();
await page.waitForURL(BASE + '/minhas-oportunidades/create');
if (!w.id) {
await page
.getByLabel('Título oficial', { exact: true })
.first()
.fill(w.title);
await page
.getByLabel('Motivo da alteração', { exact: true })
.fill(
'Ensaio visual QA do contrato e revisão por intervenientes independentes.',
);
const response = page.waitForResponse(
(r) =>
new URL(r.url()).pathname ===
'/minhas-oportunidades/drafts' &&
r.request().method() === 'POST',
);
await page
.getByRole('button', { name: 'Guardar rascunho', exact: true })
.click();
assert.equal((await response).status(), 201);
await page.waitForURL(/\/minhas-oportunidades\/\d+\/edit$/);
w.id = Number(new URL(page.url()).pathname.split('/')[2]);
w.steps.push({ actor: 21, action: 'create_incomplete_draft' });
q.save();
}
await open('/minhas-oportunidades/' + w.id + '/edit');
assert.equal(props.auth.user.id, 21);
if (!w.location || !w.document) {
q.phase(
'UI saves per-opportunity location and scanned synthetic document',
);
await open('/minhas-oportunidades/' + w.id + '/resources');
if (!w.location) {
await page
.getByRole('combobox', { name: 'Província', exact: true })
.selectOption({ label: 'Cabo Delgado (02)' })
.catch(async () => {
const select = page.getByRole('combobox', {
name: 'Província',
exact: true,
});
await select
.locator('option')
.filter({ hasText: /Cabo Delgado/i })
.waitFor({ state: 'attached' });
await select.selectOption(
await select
.locator('option')
.filter({ hasText: /Cabo Delgado/i })
.getAttribute('value'),
);
});
const district = page.getByRole('combobox', {
name: 'Distrito',
exact: true,
});
await district
.locator('option')
.filter({ hasText: /^ANCUABE(?: \(|$)/i })
.waitFor({ state: 'attached' });
await district.selectOption(
await district
.locator('option')
.filter({ hasText: /^ANCUABE(?: \(|$)/i })
.getAttribute('value'),
);
await page
.getByLabel('Papel da localização', { exact: true })
.selectOption('headquarters');
await page
.getByLabel('Motivo (20–1000 caracteres)', { exact: true })
.fill(
'Localização declarada sintética para ensaio QA do workflow.',
);
const response = page.waitForResponse(
(r) =>
new URL(r.url()).pathname.endsWith(
'/resources/locations',
) && r.request().method() === 'POST',
);
await page
.getByRole('button', {
name: 'Guardar recurso',
exact: true,
})
.click();
const r = await response;
assert.equal(r.status(), 201);
w.location = (await r.json()).id;
q.save();
await open('/minhas-oportunidades/' + w.id + '/resources');
}
if (!w.document) {
await page
.getByRole('button', { name: 'Documentos', exact: true })
.click();
await page
.getByLabel('Título', { exact: true })
.fill('QA comprovativo do workflow visual');
await page
.getByLabel('Categoria documental', { exact: true })
.fill('qa-synthetic');
await page
.getByLabel('Classificação documental', { exact: true })
.selectOption('restricted');
await page
.getByLabel('Âmbito de consulta', { exact: true })
.selectOption('internal');
await page
.getByLabel('Motivo (20–1000 caracteres)', { exact: true })
.fill(
'Anexo sintético QA, sem validade oficial ou direitos reais.',
);
await page
.locator('input[type=file]')
.setInputFiles(
path.join(DIR, 'synthetic-valid-original.pdf'),
);
const response = page.waitForResponse(
(r) =>
new URL(r.url()).pathname.endsWith(
'/resources/documents',
) && r.request().method() === 'POST',
);
await page
.getByRole('button', {
name: 'Guardar recurso',
exact: true,
})
.click();
const r = await response;
assert.equal(r.status(), 201);
w.document = (await r.json()).id;
q.save();
}
}
if (!w.primary_ensured) {
await open('/minhas-oportunidades/' + w.id + '/resources');
const location = props.resources.locations.find(
(r) => r.id === w.location,
);
assert(location);
if (!location.payload.is_primary) {
await page
.getByRole('button', {
name: 'Ver / editar / restaurar',
exact: true,
})
.click();
await page
.getByLabel('Localização principal', { exact: true })
.check();
await page
.getByLabel('Motivo (20–1000 caracteres)', { exact: true })
.fill(
'Identificação explícita da sede sintética QA para o workflow.',
);
const pending = page.waitForResponse(
(r) =>
new URL(r.url()).pathname.endsWith(
'/resources/locations/' + w.location,
) && r.request().method() === 'POST',
);
await page
.getByRole('button', {
name: 'Guardar recurso',
exact: true,
})
.click();
assert.equal((await pending).status(), 200);
await page.waitForLoadState('networkidle');
}
w.primary_ensured = true;
q.save();
}
if (!w.contract_saved) {
q.phase(
'UI fills existing canonical editors, preserving resource references',
);
await open('/minhas-oportunidades/' + w.id + '/edit');
const template = JSON.parse(
fs.readFileSync(
path.join(DIR, 'consumer-workflow-template-qa.json'),
'utf8',
),
);
assert(template.synthetic_only);
const c = {
...template.canonical,
official_title: w.title,
locations: [w.location],
documents: [w.document],
evidences: [
{
title: 'Comprovativo QA visual',
evidence_type: 'qa-synthetic',
document_id: w.document,
},
],
visibility: 'public',
};
const contract = page
.locator('fieldset')
.filter({
has: page.locator('legend').filter({
hasText:
/^Contrato da oportunidade — investment_project$/,
}),
})
.first();
const special = [
'internal_contact_id',
'sector_id',
'subsector_id',
'economic_activity_id',
];
for (const key of special)
await scalar(contract, key, 'canonical.' + key, c[key]);
for (const key of ['locations', 'documents']) {
const field = contract.locator(
'fieldset:has(> legend:text-is(' +
JSON.stringify(label(key)) +
'))',
);
const id = key === 'locations' ? w.location : w.document;
const options = props.canonicalOptions.resources[key];
const index = options.findIndex((r) => r.id === id);
assert(index >= 0);
await field.locator('input[type=checkbox]').nth(index).check();
}
for (const [key, value] of Object.entries(c)) {
if (
[
'opportunity_category',
'promoter_id',
...special,
'locations',
'documents',
].includes(key)
)
continue;
if (props.canonicalOptions.schema.properties[key])
await fill(
contract,
key,
'canonical.' + key,
props.canonicalOptions.schema.properties[key],
value,
props.canonicalOptions,
);
}
await page
.getByLabel('Motivo da alteração', { exact: true })
.fill(
'Contrato sintético preenchido pelos editores QA para revisão visual.',
);
const response = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' &&
new URL(r.url()).pathname.endsWith('/draft'),
);
await page
.getByRole('button', { name: 'Guardar rascunho', exact: true })
.click();
assert.equal((await response).status(), 200);
await page.waitForLoadState('networkidle');
await open('/minhas-oportunidades/' + w.id + '/edit');
assert.equal(props.values.canonical.official_title, w.title);
w.contract_saved = true;
w.steps.push({ actor: 21, action: 'contract_saved' });
q.save();
await q.shot(page, 'contract-saved', false);
}
if (!w.current_milestones_saved) {
await open('/minhas-oportunidades/' + w.id + '/edit');
for (let i = 0; i < props.values.canonical.milestones.length; i++)
for (const [key, value] of Object.entries({
start_date: '2026-11-01',
end_date: '2026-12-01',
responsible_name:
'Responsável sintético QA, sem nomeação operacional',
}))
await scalar(
page,
key,
'canonical.milestones.' + i + '.' + key,
value,
);
await page
.getByLabel('Motivo da alteração', { exact: true })
.fill(
'Completar marcos sintéticos QA conforme o contrato atual; datas de ensaio, sem compromisso real.',
);
const pending = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' &&
new URL(r.url()).pathname.endsWith('/draft'),
);
await page
.getByRole('button', { name: 'Guardar rascunho', exact: true })
.click();
assert.equal((await pending).status(), 200);
await page.waitForLoadState('networkidle');
w.current_milestones_saved = true;
q.save();
}
if (!w.current_publication_fields_saved) {
q.phase(
'Current contract: declared QA illustration and editorial review date',
);
if (!w.image) {
await open('/minhas-oportunidades/' + w.id + '/resources');
await page
.getByRole('button', { name: 'Imagens', exact: true })
.click();
await page
.getByLabel('Título', { exact: true })
.fill('Ilustração sintética QA do workflow');
await page
.getByLabel('Texto alternativo', { exact: true })
.fill(
'Retângulo verde com identificação de ensaio sintético QA',
);
await page
.getByLabel('Autor / fonte', { exact: true })
.fill('Asset sintético gerado localmente para ensaio QA');
await page
.getByLabel('Fonte / referência', { exact: true })
.fill(
'portal-consumer-image-fixture-qa.php — não retrata investimento real',
);
await page
.getByLabel('Tipo de imagem', { exact: true })
.selectOption('cover');
await page
.getByLabel('Direitos de utilização', { exact: true })
.selectOption('owned');
await page
.getByLabel('Âmbito de consulta', { exact: true })
.selectOption('internal');
await page
.getByLabel('Motivo (20–1000 caracteres)', { exact: true })
.fill(
'Imagem sintética própria de ensaio QA, não corresponde a um investimento real.',
);
await page
.locator('input[type=file]')
.setInputFiles(
path.join(DIR, 'consumer-workflow-synthetic.png'),
);
const pending = page.waitForResponse(
(r) =>
new URL(r.url()).pathname.endsWith(
'/resources/images',
) && r.request().method() === 'POST',
);
await page
.getByRole('button', {
name: 'Guardar recurso',
exact: true,
})
.click();
const r = await pending;
assert.equal(r.status(), 201);
w.image = (await r.json()).id;
q.save();
}
await open('/minhas-oportunidades/' + w.id + '/edit');
const field = page.locator(
'fieldset:has(> legend:text-is("Imagens guardadas"))',
);
const index = props.canonicalOptions.resources.images.findIndex(
(r) => r.id === w.image,
);
assert(index >= 0);
await field.locator('input[type=checkbox]').nth(index).check();
await scalar(
page,
'review_due_date',
'canonical.review_due_date',
'2026-11-01',
);
await page
.getByLabel('Motivo da alteração', { exact: true })
.fill(
'Identificação da ilustração QA e data editorial de ensaio conforme contrato atual.',
);
const pending = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' &&
new URL(r.url()).pathname.endsWith('/draft'),
);
await page
.getByRole('button', { name: 'Guardar rascunho', exact: true })
.click();
assert.equal((await pending).status(), 200);
await page.waitForLoadState('networkidle');
w.current_publication_fields_saved = true;
q.save();
}
if (!w.current_finance_environment_saved) {
q.phase(
'UI completes zero secured funding and explicit pending environmental classification',
);
await open('/minhas-oportunidades/' + w.id + '/edit');
const funding = page
.locator(
'fieldset:has(> legend:text-is(' +
JSON.stringify(label('secured_financing')) +
'))',
)
.first();
await funding.locator(':scope > button').click();
await funding
.getByRole('button', { name: 'Retirar linha 1', exact: true })
.click();
for (const [key, value] of Object.entries({
assessment_status: 'Avaliação pendente — ensaio sintético QA',
environmental_category:
'Classificação por determinar — ensaio sintético QA',
}))
await scalar(
page,
key,
'canonical.environmental_social.' + key,
value,
);
await page
.getByLabel('Motivo da alteração', { exact: true })
.fill(
'Declarar cenário sintético QA sem financiamento assegurado e classificação ambiental pendente.',
);
const pending = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' &&
new URL(r.url()).pathname.endsWith('/draft'),
);
await page
.getByRole('button', { name: 'Guardar rascunho', exact: true })
.click();
assert.equal((await pending).status(), 200);
await page.waitForLoadState('networkidle');
w.current_finance_environment_saved = true;
q.save();
}
if (!w.cover_derivatives_prepared) {
q.phase(
'UI cover metadata and real WebP preparation before approval',
);
await open('/minhas-oportunidades/' + w.id + '/resources');
await page
.getByRole('button', { name: 'Imagens', exact: true })
.click();
const image = page.locator('article').filter({
has: page.getByText(/^Ilustração sintética QA do workflow/),
});
await image
.getByRole('button', {
name: 'Ver / editar / restaurar',
exact: true,
})
.click();
await page
.getByLabel('Legenda', { exact: true })
.fill(
'Ilustração própria do ensaio QA, sem representação de investimento real.',
);
await page
.getByLabel('Selecionar capa de preparação', { exact: true })
.check();
await page
.getByLabel('Motivo (20–1000 caracteres)', { exact: true })
.fill(
'Identificar explicitamente a capa sintética QA e preparar derivadas verificadas.',
);
const saved = page.waitForResponse(
(r) =>
r.request().method() === 'POST' &&
new URL(r.url()).pathname.endsWith(
'/resources/images/' + w.image,
),
);
await page
.getByRole('button', { name: 'Guardar recurso', exact: true })
.click();
assert.equal((await saved).status(), 200);
await page.waitForLoadState('networkidle');
await open('/minhas-oportunidades/' + w.id + '/resources');
await page
.getByRole('button', { name: 'Imagens', exact: true })
.click();
await page
.locator('article')
.filter({
has: page.getByText(/^Ilustração sintética QA do workflow/),
})
.getByRole('button', {
name: 'Ver / editar / restaurar',
exact: true,
})
.click();
await page
.getByLabel('Motivo (20–1000 caracteres)', { exact: true })
.fill(
'Preparar WebP e miniaturas da imagem sintética QA pelo serviço existente.',
);
const prepared = page.waitForResponse(
(r) =>
r.request().method() === 'POST' &&
new URL(r.url()).pathname.includes(
'/resources/images/' + w.image + '/',
),
);
await page
.getByRole('button', {
name: 'Preparar WebP e miniaturas',
exact: true,
})
.click();
assert.equal((await prepared).status(), 200);
await page.waitForLoadState('networkidle');
w.cover_derivatives_prepared = true;
q.save();
}
await open('/minhas-oportunidades/' + w.id + '/view');
if (props.workflowState === 'draft') await transition('submit');
await actor('validator');
await open('/minhas-oportunidades/' + w.id + '/view');
if (!w.correction_requested) {
await transition('request_correction', {
correction: 'canonical.demand_statement',
});
w.correction_requested = true;
q.save();
}
await actor('promoter');
await open('/minhas-oportunidades/' + w.id + '/edit');
if (props.workflowState === 'needs_information') {
await page
.locator('[id="canonical.demand_statement"]')
.fill(
props.values.canonical.demand_statement +
' Correção visual QA: informação solicitada acrescentada.',
);
await page
.getByLabel('Motivo da alteração', { exact: true })
.fill(
'Resposta sintética QA à correção solicitada pelo Validador independente.',
);
const response = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' &&
new URL(r.url()).pathname.endsWith('/draft'),
);
await page
.getByRole('button', { name: 'Guardar rascunho', exact: true })
.click();
assert.equal((await response).status(), 200);
await page.waitForLoadState('networkidle');
w.steps.push({ actor: 21, action: 'correct_saved' });
q.save();
await transition('submit');
}
await actor('validator');
await open('/minhas-oportunidades/' + w.id + '/view');
if (props.workflowState === 'submitted')
await transition('start_review');
if (props.workflowState === 'in_review') {
for (const gate of props.canonicalOptions.gates) {
if (
!w.steps.some(
(s) => s.action === 'review_gate' && s.gate === gate,
)
)
await transition('review_gate', { gate });
}
await transition('approve');
}
await actor('publisher');
await open('/minhas-oportunidades/' + w.id + '/view');
if (props.workflowState === 'approved') await transition('publish');
assert.equal(props.workflowState, 'published');
assert.equal(props.publicationState.public_catalog, true);
w.publication = {
state: props.workflowState,
scope: props.publicationState.scope,
label: props.publicationState.label,
public_catalog: true,
};
q.proof.checks.independent_visual_cycle = true;
q.proof.checks.publication_scope_explicit = true;
await actor('promoter');
await open('/minhas-oportunidades/' + w.id + '/view');
assert.equal(props.workflowState, 'published');
assert(props.profileContext.approval);
q.proof.checks.final_state_recovered_by_promoter = true;
q.proof.result = 'passed';
} catch (e) {
q.proof.result = 'failed';
q.proof.error = sanitized(e);
if (
page &&
!['/login', '/two-factor-challenge'].includes(
new URL(page.url()).pathname,
)
) {
q.proof.failure_state = {
route: new URL(page.url()).pathname,
labels: (await page.locator('label').allTextContents()).map(
(s) => s.slice(0, 140),
),
alerts: await page.getByRole('alert').allTextContents(),
buttons: await page.getByRole('button').allTextContents(),
};
await q.shot(page, 'failure', false).catch(() => {});
}
} finally {
q.proof.finished_at = new Date().toISOString();
q.save();
await q.browser.close();
console.log(
JSON.stringify({
result: q.proof.result,
phase: q.proof.phase,
checks: q.proof.checks,
receipt: q.receipt,
}),
);
if (q.proof.result === 'failed') process.exitCode = 1;
}
})();