From caf18f40110d23de1e27b184420702883e09813f Mon Sep 17 00:00:00 2001 From: Elson Lopes Date: Fri, 28 Aug 2026 21:42:26 -0300 Subject: [PATCH] test: add blocking real user e2e gate --- .github/workflows/ci-dev.yml | 20 +++ README.md | 32 ++++ e2e-real/mqseries-user-flow.spec.ts | 267 ++++++++++++++++++++++++++++ package.json | 1 + playwright.real.config.ts | 60 +++++++ tsconfig.e2e.json | 7 +- 6 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 e2e-real/mqseries-user-flow.spec.ts create mode 100644 playwright.real.config.ts diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index 9d774a2..03ee15c 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -52,6 +52,26 @@ jobs: npm ci --prefix server if ($LASTEXITCODE -ne 0) { throw 'npm ci do BFF falhou.' } + - name: Instalar Chromium para o gate E2E real + shell: powershell + run: | + npx playwright install chromium + if ($LASTEXITCODE -ne 0) { throw 'Instalação do Chromium para o E2E real falhou.' } + + # Gate de aceitação antes de qualquer build/deploy: usa navegador, front, BFF e API reais. + # A fixture fica fora do workspace Git para impedir vazamento do documento corporativo. + - name: Gate E2E real do MQSeries + shell: powershell + env: + REAL_E2E_API_URL: ${{ vars.LAYOUTPARSER_API_URL_DEV }} + REAL_E2E_FIXTURE_DIR: ${{ vars.REAL_E2E_FIXTURE_DIR }} + REAL_E2E_LAYOUT_NAME: ${{ vars.REAL_E2E_LAYOUT_NAME }} + run: | + npm run test:e2e:real + if ($LASTEXITCODE -ne 0) { + throw 'Gate E2E real falhou; quality, build e deploy foram bloqueados.' + } + - name: Quality gates e builds same-origin shell: powershell env: diff --git a/README.md b/README.md index 551dd18..a933d05 100644 --- a/README.md +++ b/README.md @@ -410,6 +410,7 @@ produção foi desenhado para deixar essa variável vazia e usar `/api` na mesma | `npm run build:prod` | Build no modo production. | | `npm run format:check` | Prettier nos arquivos do front. | | `npm run test:e2e` | Fluxo Playwright em desktop e mobile. | +| `npm run test:e2e:real` | Fluxo de usuário contra BFF/API e fixture reais. | | `npm run contract:check` | Contrato local e OpenAPI opcional. | | `npm run audit` | Auditoria npm, bloqueando severidade moderada ou maior. | | `npm run quality` | Gate agregado do front, BFF, artefatos e contrato. | @@ -436,6 +437,37 @@ A suíte em [`e2e/`](e2e/) valida o fluxo TXT → transformação → download X área administrativa em perfis desktop e móvel. As APIs são mockadas no navegador para tornar o teste determinístico; isso não substitui um teste de integração contra o gateway e a API reais. +#### Gate E2E real do usuário + +O cenário [`mqseries-user-flow.spec.ts`](e2e-real/mqseries-user-flow.spec.ts) não intercepta nem +simula APIs. Ele inicia o front e o BFF reais, aponta o BFF para a `LayoutParserApi`, abre a página +como usuário autenticado de desenvolvimento e opera os mesmos controles da interface: busca e +seleciona o layout, abre o seletor de arquivo, anexa o TXT, processa, seleciona uma tag vazia, +gera a transformação multi-candidato, volta ao TXT, edita suas 15 posições e reprocessa o +documento. + +Além da UI, o teste exige HTTP 200 e valida que um `X-Correlation-ID` válido, novo por operação e +imutável atravessou navegador → BFF → API → navegador nas chamadas de sessão, catálogo, parse, +transformação multi-candidato e reparse. O contrato aceito é o do documento homologado: 59 linhas, +705 campos e quatro ocorrências físicas da `LINHA081`, sem a ocorrência agregada duplicada no fim. + +```powershell +$env:REAL_E2E_API_URL = 'http://127.0.0.1:5100' +$env:REAL_E2E_FIXTURE_DIR = 'C:\caminho\privado\teste' +$env:REAL_E2E_LAYOUT_NAME = 'LAY_TXT_MQSERIES_ENVNFE_4.00_NFe' +npm run test:e2e:real +``` + +No runner Windows, a localização padrão persistente é +`C:\ProgramData\LayoutParser\e2e-fixtures\mqseries`. O environment `development` pode sobrescrever +o caminho e o layout pelas variables `REAL_E2E_FIXTURE_DIR` e `REAL_E2E_LAYOUT_NAME`. Screenshots, +vídeos e traces ficam desabilitados nessa suíte para que uma falha não publique dados privados. + +Em [`ci-dev.yml`](.github/workflows/ci-dev.yml), esse cenário roda imediatamente após a instalação +das dependências. Se ele falhar, os quality gates seguintes, o build e o deploy de desenvolvimento +não executam; consequentemente, a proteção de `main` não recebe o deployment ativo necessário para +autorizar a promoção `develop → main`. + ### Aceitação com o par MQSeries real O cenário opt-in [`mqseries-positional.test.ts`](tests/real-fixture/mqseries-positional.test.ts) diff --git a/e2e-real/mqseries-user-flow.spec.ts b/e2e-real/mqseries-user-flow.spec.ts new file mode 100644 index 0000000..34bcb9b --- /dev/null +++ b/e2e-real/mqseries-user-flow.spec.ts @@ -0,0 +1,267 @@ +import { readdir, stat } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { expect, test, type Page, type Response } from '@playwright/test'; + +const EXPECTED = { + documentBytes: 35_400, + layoutName: 'LAY_TXT_MQSERIES_ENVNFE_4.00_NFe', + rawFields: 705, + physicalLines: 59, + line81Occurrences: 4, +} as const; +const CORRELATION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$/; + +interface FixtureFile { + path: string; +} + +interface ParseMetadata { + success?: boolean; + detectedType?: string; + text?: string; + fields?: unknown[]; + layout?: { name?: string }; + summary?: { + totalLines?: number; + totalFields?: number; + errorFields?: number; + }; +} + +interface LayoutCatalog { + success?: boolean; + layouts?: Array<{ + name?: string; + decryptedContent?: string; + valueContent?: string; + }>; +} + +interface TransformationResult { + success?: boolean; + candidates?: unknown[]; + warnings?: unknown[]; + pathwayDiagnostics?: unknown[]; +} + +const privateFixtureCandidates = (): string[] => { + const configured = process.env.REAL_E2E_FIXTURE_DIR?.trim(); + const programData = process.env.ProgramData?.trim() || 'C:\\ProgramData'; + return [ + ...(configured ? [resolve(configured)] : []), + resolve('.codex/temp/teste'), + resolve(programData, 'LayoutParser/e2e-fixtures/mqseries'), + ]; +}; + +const findPrivateDocument = async (): Promise => { + for (const directory of privateFixtureCandidates()) { + try { + const entries = await readdir(directory, { withFileTypes: true }); + const documents = entries.filter( + entry => entry.isFile() && entry.name.toLocaleLowerCase('en-US').endsWith('.txt') + ); + if (documents.length !== 1) continue; + + const path = join(directory, documents[0]!.name); + const metadata = await stat(path); + if (metadata.size !== EXPECTED.documentBytes) continue; + + return { path }; + } catch { + // A próxima localização privada pode estar provisionada no runner. + } + } + + throw new Error( + 'Fixture privada não encontrada. Defina REAL_E2E_FIXTURE_DIR ou provisione C:\\ProgramData\\LayoutParser\\e2e-fixtures\\mqseries.' + ); +}; + +const responsePathIs = + (path: string) => + (response: Response): boolean => { + try { + return new URL(response.url()).pathname.toLocaleLowerCase('en-US') === path; + } catch { + return false; + } + }; + +const assertCorrelationRoundTrip = async (response: Response, label: string): Promise => { + const requestId = await response.request().headerValue('x-correlation-id'); + const responseId = await response.headerValue('x-correlation-id'); + + expect( + { + label, + status: response.status(), + requestIdValid: Boolean(requestId && CORRELATION_ID_PATTERN.test(requestId)), + responseIdValid: Boolean(responseId && CORRELATION_ID_PATTERN.test(responseId)), + preservedEndToEnd: requestId === responseId, + }, + `${label}: o correlation ID deve atravessar navegador → BFF → API → navegador sem mudar` + ).toEqual({ + label, + status: 200, + requestIdValid: true, + responseIdValid: true, + preservedEndToEnd: true, + }); + + return requestId!; +}; + +const selectRealLayout = async (page: Page, layoutName: string): Promise => { + const catalogResponsePromise = page.waitForResponse( + responsePathIs('/api/layoutdatabase/mqseries-nfe') + ); + await page.getByRole('button', { name: 'Buscar Layout' }).click(); + const catalogResponse = await catalogResponsePromise; + const catalog = (await catalogResponse.json()) as LayoutCatalog; + const matchingLayouts = (catalog.layouts ?? []).filter(layout => layout.name === layoutName); + + expect({ success: catalog.success, matchingLayouts: matchingLayouts.length }).toEqual({ + success: true, + matchingLayouts: 1, + }); + + await page.getByRole('combobox', { name: 'Selecionar Layout' }).click(); + await page.getByRole('searchbox', { name: 'Buscar layout por nome ou GUID' }).fill(layoutName); + await page.getByRole('option', { name: new RegExp(layoutName) }).click(); + await expect( + page.getByRole('combobox', { name: `Layout selecionado: ${layoutName}` }) + ).toBeVisible(); + return catalogResponse; +}; + +const readParseMetadata = async (response: Response): Promise => + (await response.json()) as ParseMetadata; + +const expectCorrectParse = (payload: ParseMetadata): void => { + expect({ + success: payload.success, + detectedType: payload.detectedType, + layoutName: payload.layout?.name, + textLength: payload.text?.length, + fields: payload.fields?.length, + totalLines: payload.summary?.totalLines, + totalFields: payload.summary?.totalFields, + errorFields: payload.summary?.errorFields, + }).toEqual({ + success: true, + detectedType: 'mqseries', + layoutName: EXPECTED.layoutName, + textLength: EXPECTED.documentBytes, + fields: EXPECTED.rawFields, + totalLines: EXPECTED.physicalLines, + totalFields: EXPECTED.rawFields, + errorFields: 0, + }); +}; + +test('usuário processa e edita o MQSeries real com correlação ponta a ponta', async ({ page }) => { + const document = await findPrivateDocument(); + const layoutName = process.env.REAL_E2E_LAYOUT_NAME?.trim() || EXPECTED.layoutName; + const correlationIds: string[] = []; + + const sessionResponsePromise = page.waitForResponse(responsePathIs('/api/session')); + await page.goto('/upload'); + const sessionResponse = await sessionResponsePromise; + correlationIds.push(await assertCorrelationRoundTrip(sessionResponse, 'sessão')); + const session = (await sessionResponse.json()) as { + authenticated?: boolean; + user?: { name?: string }; + }; + expect({ authenticated: session.authenticated, identified: Boolean(session.user?.name) }).toEqual( + { + authenticated: true, + identified: true, + } + ); + + const catalogResponse = await selectRealLayout(page, layoutName); + correlationIds.push(await assertCorrelationRoundTrip(catalogResponse, 'catálogo de layouts')); + + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Selecionar arquivo' }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(document.path); + await expect(page.locator('#txtFile-status')).toContainText('Arquivo selecionado'); + + const parseResponsePromise = page.waitForResponse(responsePathIs('/api/parse/upload')); + await page.getByRole('button', { name: 'Processar Documento' }).click(); + const parseResponse = await parseResponsePromise; + correlationIds.push(await assertCorrelationRoundTrip(parseResponse, 'parse inicial')); + expectCorrectParse(await readParseMetadata(parseResponse)); + + const provenance = page.locator('.document-provenance'); + await expect(provenance).toContainText('Resultado vinculado a'); + await expect(provenance).toContainText(`${EXPECTED.documentBytes} bytes`); + await expect(provenance).toContainText(layoutName); + + await page.getByRole('tab', { name: 'XML Transformação Final' }).click(); + const transformationResponsePromise = page.waitForResponse( + responsePathIs('/api/transformationexecution/execute-candidates') + ); + await page.getByRole('button', { name: 'Gerar Transformação XML' }).click(); + const transformationResponse = await transformationResponsePromise; + correlationIds.push( + await assertCorrelationRoundTrip(transformationResponse, 'transformação multi-candidato') + ); + const transformation = (await transformationResponse.json()) as TransformationResult; + expect({ + success: transformation.success, + candidatesIsArray: Array.isArray(transformation.candidates), + warningsIsArray: Array.isArray(transformation.warnings), + diagnosticsIsArray: Array.isArray(transformation.pathwayDiagnostics), + }).toEqual({ + success: true, + candidatesIsArray: true, + warningsIsArray: true, + diagnosticsIsArray: true, + }); + await page.getByRole('tab', { name: 'TXT Posicional' }).click(); + + const line81Labels = page.getByText(/^LINHA081 - Ocorrência [1-4]$/, { exact: true }); + await expect(line81Labels).toHaveCount(EXPECTED.line81Occurrences); + + await page + .getByPlaceholder('Buscar campos (nome, valor ou GUID)...') + .fill('NroProtocoloAutorizacao'); + const protocolField = page + .getByRole('button', { + name: /Selecionar campo NroProtocoloAutorizacao, ocorrência 1: vazio/, + }) + .first(); + await protocolField.click(); + + const inspector = page.getByRole('complementary', { name: 'Inspetor de rastreabilidade' }); + await expect(inspector).toContainText('LINHA000 · ocorrência 1'); + await expect(inspector).toContainText('Posições 75–89 · 15 caracteres'); + await inspector.getByRole('button', { name: 'Editar valor' }).click(); + + const editor = page.getByRole('dialog', { name: 'Editar NroProtocoloAutorizacao' }); + await expect(editor).toContainText('Linha física 2'); + await expect(editor).toContainText('Posições 75–89'); + await expect(editor).toContainText('15 posições'); + await editor.getByLabel('Novo valor').fill('N'.repeat(15)); + await editor.getByRole('button', { name: 'Aplicar no TXT' }).click(); + + const editActions = page.getByRole('region', { name: 'Ações do TXT editado' }); + await expect(editActions).toContainText('1 alteração(ões) nesta sessão'); + + const reparseResponsePromise = page.waitForResponse(responsePathIs('/api/parse/upload')); + await editActions.getByRole('button', { name: 'Reprocessar e revalidar' }).click(); + const reparseResponse = await reparseResponsePromise; + correlationIds.push(await assertCorrelationRoundTrip(reparseResponse, 'reparse após edição')); + expectCorrectParse(await readParseMetadata(reparseResponse)); + await expect(editActions.getByRole('status')).toContainText( + 'Documento reprocessado e revalidado sem erros posicionais' + ); + + expect({ total: correlationIds.length, unique: new Set(correlationIds).size }).toEqual({ + total: 5, + unique: 5, + }); +}); diff --git a/package.json b/package.json index 900fe2f..58be7dd 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:coverage": "vitest run --coverage", "test:fixture:mqseries": "tsc -p tsconfig.real-fixture.json --noEmit && vitest run --config vitest.real-fixture.config.ts", "test:e2e": "playwright test", + "test:e2e:real": "tsc -p tsconfig.e2e.json --noEmit && playwright test --config playwright.real.config.ts", "contract:check": "node scripts/check-api-contract.mjs", "artifacts:validate": "node scripts/validate-production-artifacts.mjs", "audit": "npm audit --audit-level=moderate", diff --git a/playwright.real.config.ts b/playwright.real.config.ts new file mode 100644 index 0000000..171d615 --- /dev/null +++ b/playwright.real.config.ts @@ -0,0 +1,60 @@ +import { defineConfig, devices } from '@playwright/test'; + +const apiUrl = process.env.REAL_E2E_API_URL?.trim() || 'http://127.0.0.1:5100'; +const bffUrl = 'http://127.0.0.1:3200'; +const frontendUrl = 'http://127.0.0.1:3001'; + +/** + * Navegador + front + BFF + API reais. Nenhuma rota é mockada e nenhum artefato visual é salvo: + * screenshots, traces e vídeos poderiam conter dados do documento privado. + */ +export default defineConfig({ + testDir: './e2e-real', + // O pathway de transformação pode consumir ~150 s sem GPU; o gate inclui folga para + // navegação, parse, edição e reparse sem transformar lentidão legítima em falso negativo. + timeout: 300_000, + fullyParallel: false, + workers: 1, + retries: 0, + reporter: process.env.CI ? [['github'], ['line']] : 'list', + expect: { timeout: 30_000 }, + use: { + ...devices['Desktop Chrome'], + baseURL: frontendUrl, + screenshot: 'off', + trace: 'off', + video: 'off', + }, + webServer: [ + { + command: 'npm --prefix server run dev', + url: `${bffUrl}/health`, + timeout: 120_000, + reuseExistingServer: false, + env: { + ...process.env, + NODE_ENV: 'development', + BFF_HOST: '127.0.0.1', + BFF_PORT: '3200', + BFF_PUBLIC_ORIGIN: bffUrl, + BFF_DEV_AUTH_ENABLED: 'true', + BFF_ADMIN_USERS: 'layoutparser.e2e', + BFF_RATE_LIMIT_MAX: '1000', + BFF_LOG_LEVEL: 'warn', + LAYOUTPARSER_API_URL: apiUrl, + }, + }, + { + command: 'npm run dev:front -- --host 127.0.0.1 --port 3001', + url: `${frontendUrl}/upload`, + timeout: 120_000, + reuseExistingServer: false, + env: { + ...process.env, + VITE_DEV_BFF_PROXY_TARGET: bffUrl, + VITE_DEV_BFF_USER: 'layoutparser.e2e', + VITE_DEV_BFF_ROLES: 'e2e', + }, + }, + ], +}); diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json index aaf0726..9833a9c 100644 --- a/tsconfig.e2e.json +++ b/tsconfig.e2e.json @@ -4,5 +4,10 @@ "noEmit": true, "types": ["node", "@playwright/test"] }, - "include": ["playwright.config.ts", "e2e/**/*.ts"] + "include": [ + "playwright.config.ts", + "playwright.real.config.ts", + "e2e/**/*.ts", + "e2e-real/**/*.ts" + ] }