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( /]*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; } })();