Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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)
Expand Down
267 changes: 267 additions & 0 deletions e2e-real/mqseries-user-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -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<FixtureFile> => {
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<string> => {
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<Response> => {
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<ParseMetadata> =>
(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,
});
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading