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
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ bash scripts/precommit-checks.sh
.\scripts\precommit-checks.ps1
```

**Testes E2E do dashboard (Playwright):**
```powershell
cd test/frontend_smoke
npm ci
npx playwright install chromium
npm run test:e2e
```
Os testes iniciam seus próprios servidores nas portas 3100–3102; não os inicie manualmente. A suíte Playwright está serializada porque alguns cenários alteram o estoque simulado. Consulte [`docs/CI-CD.md`](docs/CI-CD.md) para projetos, cobertura e execução em CI.

**Servidor Node:**
```bash
cd server
Expand Down
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ Convenção de seções: `Adicionado`, `Alterado`, `Corrigido`, `Segurança`, `R

## [Não publicado]

### Adicionado

- **Cobertura E2E do dashboard (card #27):** Playwright agora valida feedback de comandos ocupados/inválidos/limitados, estoque esgotado e recuperação por reset, reconexão WebSocket sincronizando estado, e `/api/status` nos modos servidor sem broker e simulador. O simulador aceita `ESTOQUE_INICIAL` (padrão 15) para testes isolados; execução documentada em `docs/CI-CD.md` e `CONTRIBUTING.md`.

### Alterado

- **Bancada — esteiras B e C funcionando e roteiro da Semana 8 (03/10/2026)**
Expand Down
15 changes: 15 additions & 0 deletions docs/CI-CD.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,21 @@ npm update <package-name>

#### 5. Lógica do firmware do Uno (`firmware-logica`)
- **O quê:** compila `arduino/data_flow_inventory/src/logica/` com g++ e roda `test/firmware_uno` (FSM, marcos, filtro, protocolo, telas e causa do reset, com tempo simulado)

### 5. Testes E2E do dashboard (Playwright)

Os testes vivem em `test/frontend_smoke/` e usam Playwright com Chromium. Na raiz do repositório, após instalar dependências em `server/`, `simulator/` e `test/frontend_smoke/` (`npm ci` em cada pasta):

```powershell
cd test/frontend_smoke
npx playwright install chromium
npm run test:e2e
```

`npm run test:e2e:ci` usa os repórteres de lista e HTML. Os web servers Playwright iniciam automaticamente: simulador normal em `localhost:3100`, servidor real com broker propositalmente inalcançável em `localhost:3101`, e simulador isolado com `ESTOQUE_INICIAL=1` em `localhost:3102`. Os projetos são serializados (`workers: 1`) porque o simulador normal mantém estoque compartilhado durante a execução. Em CI há uma retentativa (`retries: 1`) e a execução para após três falhas (`maxFailures: 3`); localmente não há retentativas. Não há `globalTimeout` do Playwright nem `timeout-minutes` no job E2E; cada processo servidor possui timeout de inicialização de 30 s e cada teste limite de 90 s.

A cobertura inclui comandos recusados (sistema ocupado, peça inválida e rate limit), exaustão de estoque e reset, reconexão WebSocket com estado sincronizado, e `/api/status` (503 do servidor sem broker e 404 no simulador). O contrato HTTP 200 com broker conectado é validado pelo harness de integração com broker em processo em `test/server_metrics/integracao/harness.test.mjs`.

- **Contrato:** os testes `*_igual_v2` garantem que as mensagens JSON continuam idênticas às da v2.x
- **Local:** `docker run --rm -v "<repo>:/w" -w /w gcc:14 make -C test/firmware_uno test` (ou `make -C test/firmware_uno test` com g++ instalado)

Expand Down
7 changes: 6 additions & 1 deletion simulator/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ const PORT = parseInt(process.env.PORT, 10) || 3000;
const DELAY_VERIFICACAO = 300; // ms — delay simulado da verificação
const DELAY_ACIONAMENTO = 200; // ms — delay simulado do acionamento
const DELAY_ENTREGA = 1500; // ms — delay simulado da entrega
const ESTOQUE_INICIAL = 15; // peças de cada tipo (igual ao firmware)
// Peças de cada tipo (igual ao firmware: 15). ESTOQUE_INICIAL permite subir o
// simulador com menos peças (ex.: os testes E2E de "sem_estoque" usam 1).
const ESTOQUE_INICIAL = Number.isInteger(parseInt(process.env.ESTOQUE_INICIAL, 10))
&& parseInt(process.env.ESTOQUE_INICIAL, 10) >= 0
? parseInt(process.env.ESTOQUE_INICIAL, 10)
: 15;

// Intervalo mínimo (ms) entre comandos de um mesmo cliente (anti-flood).
// Mesmo comportamento do servidor real (../server/server.js).
Expand Down
59 changes: 59 additions & 0 deletions test/frontend_smoke/api-status.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// E2E5 (card #27): contrato de GET /api/status.
// - servidor real sem broker (porta 3101): 503 com o schema completo;
// - simulador (porta 3100): a rota não existe (404) e o dashboard tolera isso.
// O caso 200 (broker conectado) exige um broker MQTT; é coberto por
// test/server_metrics/integracao/harness.test.mjs (aedes em processo, mqtt=true → 200).
import { test, expect } from '@playwright/test';
import { coletarErros, esperarSemErros } from './helpers.mjs';

test.describe('servidor real, broker inalcançável', () => {
test('E2E5 — GET /api/status responde 503 com o schema esperado', async ({ request }) => {
const r = await request.get('/api/status');
expect(r.status()).toBe(503);
expect(r.headers()['content-type']).toMatch(/application\/json/);
const c = await r.json();
expect(c).toMatchObject({
server: 'online',
mqtt: false,
brokerUrl: 'mqtt://127.0.0.1',
clientesWs: expect.any(Number),
gateway: 'desconhecido',
uptime: expect.any(Number),
timestamp: expect.any(String),
metricas: {
iniciadoEm: expect.any(String),
mensagensPorTopico: expect.any(Object),
comandosPublicados: expect.any(Number),
comandosRejeitados: expect.any(Number),
},
});
expect(c.metricas).toHaveProperty('ultimaMensagemEm');
expect(Number.isNaN(Date.parse(c.timestamp))).toBe(false);
expect(Number.isNaN(Date.parse(c.metricas.iniciadoEm))).toBe(false);
});

test('E2E5 — com o broker fora, comando do dashboard é recusado com "Broker MQTT offline"', async ({ page }) => {
await page.goto('/');
await page.evaluate(() => {
window.__errosComando = [];
window.dfiSocket.on('comando_erro', (e) => window.__errosComando.push(e.erro));
window.dfiSocket.emit('solicitar_peca', { peca: 'A' });
});
await expect.poll(() => page.evaluate(() => window.__errosComando)).toEqual(['Broker MQTT offline']);
await expect(page.locator('#historico-lista .historico-msg', { hasText: 'Erro: Broker MQTT offline' })).toHaveCount(1);
const c = await (await page.request.get('/api/status')).json();
expect(c.metricas.comandosRejeitados).toBeGreaterThanOrEqual(1);
});
});

test.describe('simulador', () => {
test('E2E5 — o simulador não expõe /api/status (404) e o dashboard segue sem erros', async ({ page, request }) => {
const resposta = await request.get('/api/status');
expect(resposta.status()).toBe(404);
const erros = coletarErros(page);
await page.goto('/#/arquitetura');
await expect(page.locator('#arq-modo')).toContainText('Simulador');
await expect(page.locator('#arq-palco')).toHaveAttribute('data-pronto', /3d|fallback/);
esperarSemErros(erros);
});
});
75 changes: 75 additions & 0 deletions test/frontend_smoke/comandos-recusados.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// E2E3 e E2E6 (card #27): comandos recusados mostram feedback no histórico do dashboard.
// Backend: simulador (porta 3100, estoque 15). O caso sem_estoque está em sem-estoque.spec.mjs
// (simulador dedicado com 1 peça por tipo), para não esgotar o estoque dos outros specs.
import { test, expect } from '@playwright/test';
import { coletarErros, esperarSemErros, historicoContendo, gravarErrosComando, lerErrosComando } from './helpers.mjs';

test('E2E3 — pedido com o sistema ocupado é recusado e o histórico mostra o motivo', async ({ browser }) => {
// Rate limit é por socket: um segundo dashboard (outra aba) não é barrado por ele,
// então a recusa vem do estado do sistema, não da frequência.
const contexto = await browser.newContext();
const principal = await contexto.newPage();
const segunda = await contexto.newPage();
const erros = coletarErros(principal);
try {
await principal.goto('/');
await segunda.goto('/');
await expect(principal.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO');
await expect(segunda.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO');

await principal.locator('#btn-solicitar-a').click();
// Aguarda o evento de pedido aceite confirmar que o simulador saiu do estado inicial.
await expect(historicoContendo(segunda, 'Peça A solicitada')).toHaveCount(1);
await expect(segunda.locator('#estado-atual')).not.toHaveText('AGUARDANDO_PEDIDO');

// Os botões ficam desabilitados fora de AGUARDANDO_PEDIDO; o pedido vai direto pelo socket.
await segunda.evaluate(() => window.dfiSocket.emit('solicitar_peca', { peca: 'B' }));
await expect(historicoContendo(segunda, 'Erro: Sistema ocupado')).toHaveCount(1);

// O pedido original não é afetado: A é entregue e o sistema volta a aguardar.
await expect(principal.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO');
await expect(historicoContendo(principal, 'Peça A entregue')).toHaveCount(1);
await expect(principal.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO');
await expect(historicoContendo(principal, 'Sistema ocupado')).toHaveCount(0);
esperarSemErros(erros);
} finally {
await contexto.close();
}
});

test('E2E3 — peça inválida é recusada com feedback e o sistema não muda de estado', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO');
await gravarErrosComando(page);

await page.evaluate(() => window.dfiSocket.emit('solicitar_peca', { peca: 'Z' }));
await expect(historicoContendo(page, 'Erro: Peça inválida: Z')).toHaveCount(1);
expect(await lerErrosComando(page)).toEqual(['Peça inválida: Z']);
await expect(page.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO');
});

test('E2E6 — rate limit: segundo comando em < COMANDO_INTERVALO_MS é recusado; depois do intervalo, aceito', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO');
await gravarErrosComando(page);

// Dois comandos no mesmo instante (mesmo evento do navegador): o 2º cai dentro do intervalo
// de 500 ms do simulador, antes mesmo de qualquer verificação de estado.
await page.evaluate(() => {
window.dfiSocket.emit('solicitar_peca', { peca: 'A' });
window.dfiSocket.emit('solicitar_peca', { peca: 'B' });
});
await expect(historicoContendo(page, 'Muitos comandos — aguarde 500ms entre envios')).toHaveCount(1);
expect(await lerErrosComando(page)).toEqual(['Muitos comandos — aguarde 500ms entre envios']);

// O 1º pedido segue normalmente (A entregue); B nunca foi aceito.
await expect(page.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO');
await expect(historicoContendo(page, 'Peça A entregue').count()).resolves.toBeGreaterThanOrEqual(1);
expect(await lerErrosComando(page)).toEqual(['Muitos comandos — aguarde 500ms entre envios']);

// Passado o intervalo, o comando volta a ser aceito (o botão usa o mesmo caminho).
const antes = await historicoContendo(page, 'Peça B entregue').count();
await page.locator('#btn-solicitar-b').click();
await expect(historicoContendo(page, 'Peça B entregue')).toHaveCount(antes + 1);
expect(await lerErrosComando(page)).toHaveLength(1);
});
20 changes: 20 additions & 0 deletions test/frontend_smoke/helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ function linhas(page, texto) {
return page.locator('#historico-lista .historico-msg', { hasText: new RegExp(`^${texto}$`) });
}

/** Linhas do histórico que CONTÊM o trecho (texto literal, sem regex). */
export function historicoContendo(page, trecho) {
return page.locator('#historico-lista .historico-msg', { hasText: trecho });
}

/**
* Registra, dentro da página, os `comando_erro` recebidos pelo socket do dashboard
* (window.__errosComando). Use lerErrosComando para ler.
*/
export async function gravarErrosComando(page) {
await page.evaluate(() => {
window.__errosComando = [];
window.dfiSocket.on('comando_erro', (e) => window.__errosComando.push(e.erro));
});
}

export async function lerErrosComando(page) {
return page.evaluate(() => window.__errosComando);
}

/**
* Roteiro dos botões de controle (cenário 1, spec §7.2).
* Conta as linhas antes de clicar, porque o simulador reenvia os últimos
Expand Down
2 changes: 2 additions & 0 deletions test/frontend_smoke/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
"type": "module",
"scripts": {
"test": "playwright test",
"test:e2e": "playwright test",
"test:e2e:ci": "playwright test --reporter=list,html",
"test:simulador": "playwright test --project=simulador"
},
"devDependencies": {
Expand Down
31 changes: 22 additions & 9 deletions test/frontend_smoke/playwright.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,25 @@
// DATA FLOW INVENTORY — Configuração dos testes E2E (Playwright)
// ------------------------------------------------------------
// Projeto "simulador": sobe ../../simulator na porta 3100 (sem helmet).
// Projeto "servidor-sem-broker": sobe ../../server na porta 3101 com um
// broker inalcançável, para exercitar a CSP real do helmet e o 503 de
// /api/status. O dotenv não sobrescreve variáveis já definidas aqui.
// Projeto "servidor-sem-broker": sobe ../../server na porta 3101 com broker
// inalcançável; projetos api-status separam os contratos 404 (simulador) e
// 503 (servidor) sem reaproveitar o mesmo arquivo contra a URL errada.
// Projeto "simulador-sem-estoque": outro simulador (porta 3102) com
// ESTOQUE_INICIAL=1, só para o cenário sem_estoque.
// ============================================================
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: '.',
testMatch: /.*\.spec\.mjs/,
// O simulador é um processo único com estado (estoque = 5 por peça):
// testes em série e sem retentativas, para não esgotar o estoque.
// O simulador é stateful (estoque inicial = 15 por peça); manter testes em série.
fullyParallel: false,
workers: 1,
retries: 0,
// Carga da página (~9 s) + roteiro dos botões (~30 s) + navegação: margem para CI.
retries: process.env.CI ? 1 : 0,
maxFailures: 3,
// Carga da página (~9 s) + roteiros com entrega simulada: margem para CI sem globalTimeout.
timeout: 90_000,
// Máquina de bancada sob carga: asserções com folga (carga ~9 s).
// Máquina de bancada/CI sob carga: asserções com folga.
expect: { timeout: 20_000 },
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
use: {
Expand All @@ -29,8 +31,12 @@ export default defineConfig({
launchOptions: { args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] },
},
projects: [
{ name: 'simulador', testIgnore: /csp\.spec\.mjs/, use: { baseURL: 'http://localhost:3100' } },
{ name: 'simulador', testIgnore: /(csp|sem-estoque|api-status)\.spec\.mjs/, use: { baseURL: 'http://localhost:3100' } },
{ name: 'servidor-sem-broker', testMatch: /csp\.spec\.mjs/, use: { baseURL: 'http://localhost:3101' } },
{ name: 'api-status-simulador', testMatch: /api-status\.spec\.mjs/, grep: /simulador não expõe/, use: { baseURL: 'http://localhost:3100' } },
{ name: 'api-status-servidor', testMatch: /api-status\.spec\.mjs/, grepInvert: /simulador não expõe/, use: { baseURL: 'http://localhost:3101' } },
// Simulador dedicado com 1 peça por tipo: o caso sem_estoque não gasta o estoque do principal.
{ name: 'simulador-sem-estoque', testMatch: /sem-estoque\.spec\.mjs/, use: { baseURL: 'http://localhost:3102' } },
],
webServer: [
{
Expand All @@ -40,6 +46,13 @@ export default defineConfig({
reuseExistingServer: false,
timeout: 30_000,
},
{
command: 'node ../../simulator/server.js',
url: 'http://localhost:3102',
env: { PORT: '3102', ESTOQUE_INICIAL: '1' },
reuseExistingServer: false,
timeout: 30_000,
},
{
command: 'node ../../server/server.js',
url: 'http://localhost:3101',
Expand Down
43 changes: 43 additions & 0 deletions test/frontend_smoke/resiliencia.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,49 @@ test('cenário 4c — three.module.min.js bloqueado: mímico SVG, arquitetura 2D
esperarSemErros(erros);
});

test('E2E4 — queda do WebSocket: "Sem enlace", reconexão e estado restaurado (estoque atualizado enquanto offline)', async ({ browser }) => {
const contexto = await browser.newContext();
const painel = await contexto.newPage();
const outra = await contexto.newPage();
try {
await painel.goto('/');
await outra.goto('/');
await expect(painel.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO');
await expect(outra.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO');
await expect(painel.locator('#mqtt-status')).toContainText('Conectado');
const antes = parseInt(await painel.locator('#estoque-a').innerText(), 10);
expect(antes).toBeGreaterThan(0);

// Queda do transporte (equivale a perder a rede), sem reconexão automática:
// assim o servidor muda de estado enquanto este painel está comprovadamente fora.
await painel.evaluate(() => {
window.dfiSocket.io.reconnection(false);
window.dfiSocket.io.engine.close();
});
await expect(painel.locator('#mqtt-status')).toContainText('Sem enlace');
expect(await painel.evaluate(() => window.dfiSocket.connected)).toBe(false);

await outra.locator('#btn-solicitar-a').click();
await expect(outra.locator('#estoque-a')).toHaveText(String(antes - 1));
await expect(outra.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO');
// O painel offline não recebeu a atualização.
await expect(painel.locator('#estoque-a')).toHaveText(String(antes));

// Volta a rede: o estado_inicial devolve o estoque atual e o histórico registra a reconexão.
await painel.evaluate(() => {
window.dfiSocket.io.reconnection(true);
window.dfiSocket.connect();
});
await expect(painel.locator('#mqtt-status')).toContainText('Conectado');
await expect(painel.locator('#estoque-a')).toHaveText(String(antes - 1));
await expect(painel.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO');
await expect(painel.locator('#historico-lista .historico-msg', { hasText: 'Reconectado ao servidor' })).toHaveCount(1);
await expect(painel.locator('#btn-solicitar-a')).toBeEnabled();
} finally {
await contexto.close();
}
});

test('cenário 4d — CDN do Socket.IO inacessível: botões funcionam (cliente servido localmente)', async ({ page }) => {
await page.route('https://cdn.socket.io/**', (r) => r.abort());
const erros = coletarErros(page, /cdn\.socket\.io/);
Expand Down
Loading
Loading