diff --git a/docs/changelog/2026-08-24-b195-f1-wizard-s-gate.md b/docs/changelog/2026-08-24-b195-f1-wizard-s-gate.md new file mode 100644 index 00000000..5f2df500 --- /dev/null +++ b/docs/changelog/2026-08-24-b195-f1-wizard-s-gate.md @@ -0,0 +1 @@ +**B195-F1 — gate S: (stream settle) no último spec exposto à duplicação do wizard (2026-08-24):** `campaignRegisterDemand.e2e.spec.ts` ('creates a demand from the final step') recebe `waitForStreamSettled(page)` após o `goto` do passo final — o HTML de prod-build sob carga contém a árvore do shell + form duas vezes (cópia hidden `div[id^="S:*"]`, artefato OPS83/C106 #517; diag DOM em #659: ~15KB, action placeholder pré-hydration vs `$ACTION_REF_1`) e o spec ficou fora dos 21 call sites migrados pelo E2E-DEBT-S-GATE. O `.first()` legado (cd469857) permanece com comentário corrigido; kind/body/fill/submit seguem SEM escopo de propósito — rede de deteção que mantém falhando um form genuinamente duplicado em produção. Raiz no Next 15.4.11 (streaming RSC/form-actions sob concorrência) registrada como débito com gatilhos na Issue #879. Zero product code; smoke local E2E_PROD=1 3/3 verde. diff --git a/docs/plans/b195-f1-wizard-form-duplicado-streaming-impl.md b/docs/plans/b195-f1-wizard-form-duplicado-streaming-impl.md new file mode 100644 index 00000000..7652848b --- /dev/null +++ b/docs/plans/b195-f1-wizard-form-duplicado-streaming-impl.md @@ -0,0 +1,141 @@ +# Impl: B195-F1 — wizard Registrar pedido duplica o form no HTML sob carga (Next form-actions SSR) + +Status: aprovado (gate humano 2026-08-24) +Atualizado em: 2026-08-24 +Issue: #659 +Intenção: body da Issue #659 (kind defect; P2) +Appetite restante: herdado — fix mínimo no spec e2e + documentação da causa-raiz; zero product code; ataque à raiz no Next vai para débito com gatilho + +## Leitura da intenção + +- **Outcome:** `campaignRegisterDemand` ('creates a demand from the final step') verde de forma estável — inclusive suíte completa/prod-build sob carga (CI do PR) — sem mascarar regressão real (um form **genuinamente** duplicado em prod DEVE continuar falhando), sem mudança de product code, e com a causa-raiz documentada ligada ao artefato conhecido de streaming. +- **O que NÃO negociar:** deteção de duplicação genuína permanece no spec (locators sem escopo continuam expostos a strict mode); nenhum "conserto" no product code para satisfazer o teste; aceites de produto do wizard intactos (A5 staff gate L84–86, B43 redirect de leader, dispatch manual C140 do `WizardStepFormChrome`). +- **O que reavaliar:** a intenção levantou "possível cache/data-cache race nas form-actions do Next sob concorrência" como direção de investigação. A evidência do explorador reclassifica isso: a duplicação é a **mesma classe do artefato já catalogado** (OPS83/C106, Issue #517) — páginas dinâmicas (esta awaita `searchParams`, `acoes/[slug]/page.tsx:50`) streamam uma cópia hidden `div[id^="S:*"]` do shell que some sozinha (~1–5s), **só em build de produção**. Os componentes renderizam UMA vez; o gap de ~15KB com props idênticas ('Wanderley' nos dois) e action pré-hydration placeholder na 1ª cópia vs `$ACTION_REF_1` na 2ª é o snapshot de template do stream, não estado duplicado da aplicação. Consequência: não há investigação nova de servidor a fazer agora — há um spec e2e a menos do gate canônico (`waitForStreamSettled`, extraído hoje em `docs/changelog/2026-08-24-e2e-debt-s-gate.md`; `campaignRegisterDemand` ficou fora dos 21 migrados). + +## Abordagem recomendada + +```mermaid +flowchart LR + A[goto registrar-pedido] --> B[waitForStreamSettled] + B --> C[copia S: sai do DOM] + C --> D[assertions com locators sem escopo] + D --> E{duplicacao persiste apos o gate?} + E -- nao, transiente --> F[passa] + E -- sim, genuina --> G[falha - sinal preservado] +``` + +**Opções consideradas:** A | B | C +**Recomendação: A** — aplicar o gate canônico `waitForStreamSettled(page)` no test 'creates a demand…' logo após o `page.goto` do passo final do wizard (`tests/e2e/campaignRegisterDemand.e2e.spec.ts:26`), e reescrever o comentário do `.first()` (L38–39) com a causa real. Porque: (a) o helper já existe, está validado por 21 call sites desde OPS83 e é o idioma exato do artefato (depth check: reusar o owner, não inventar); (b) o gate espera o commit do stream e **falha por timeout** se a cópia nunca sumir — converte "transiente presente na hora da assert" em espera, mas mantém o sinal de duplicação persistente; (c) é o menor diff possível dentro do appetite de um defect P2 de teste. + +**Rejeitadas:** + +- **B — espalhar `.first()`/escopos pelos demais locators** (kind L37, body L41/L45, click L46, topBar L29–32): mascararia a própria regressão que o aceite manda preservar — um form duplicado genuíno em prod passaria pelo spec. Os locators sem escopo são a **rede de deteção**, não o bug. +- **C — atacar a raiz no Next agora** (upgrade 15.4.11→latest, mexer em `loading.tsx`, forçar estático na rota autenticada, experimentar PPR): custo alto, risco largo em rota autenticada de campanha, e o sintoma é transiente que auto-resolve em ~1–5s — sem evidência de dor em produção (nenhum relato de usuário; o browser hidrata e funciona). Vira débito com gatilho (Decisão 3). + +### Decisões de engenharia + +**Decisão 1 — escopo do gate: só o test 1 do describe.** + +```text +Opções: A | B +Recomendação: A — gate apenas no test 'creates a demand…' (após o goto L26, + antes do bloco de assertions L28+). +Alternativas rejeitadas: + B — gate nos 3 tests do describe — porque a evidência não pede: o repro + determinístico (6/7 runs suíte cheia prod local; CI do B195/D11) é do test 1; + 'goes back…' (L57) e 'keeps the final step away from leaders' (L74) nunca + flakaram nesta classe (o 2º sequer renderiza o wizard — leader leva redirect + B43 com goto abortado). Adicionar gate especulativo é latência e diff sem + repro. Gatilho de revisitação: se qualquer um dos dois flakar em CI com + strict-mode violation pós-merge, o fix é o mesmo one-liner. +``` + +Nota sobre `toHaveCount(0)` (L42/L43/L89): count **não** viola strict mode — ficam intocados por natureza. O goto tardio para `/campanha/demandas` (L53) também fica sem gate pela mesma regra de evidência (nunca flakou; assert de link único com título derivado). + +**Decisão 2 — o `.first()` existente (L40) permanece, com comentário corrigido.** + +```text +Opções: A | B +Recomendação: A — manter `.first()` no locator da atividade e REESCREVER o + comentário: a causa real é a cópia S: do shell INTEIRO (CampaignWizardShell + + form, ~15KB, OPS83/C106 Issue #517), não "combobox duplicada". +Alternativas rejeitadas: + B — remover o `.first()` agora que o gate cobre o transiente — porque não compra + poder de deteção algum (qualquer duplicação que o L40 pegaria o L37 — kind, + sem escopo — pega primeiro) e adiciona risco: se existir variante de duplicação + não coberta pelo gate, o spec volta a flakar exatamente no ponto de hoje. + Precedente cd469857 mantém o mesmo `.first()` em campaignActivity.e2e.spec.ts:112-114. +``` + +A rede anti-mascaramento fica explícita no comentário novo: kind (L37), body (L41), fill (L45) e submit (L46) seguem **sem** escopo — duplicação genuína falha neles mesmo com o `.first()` no L40. + +**Decisão 3 — raiz no Next vira débito com gatilho, registrado na entrega.** + +```text +Opções: A | B +Recomendação: A — abrir issue de débito (referenciando #659, #517 e este plano) + descrevendo a suspeita de raiz (streaming RSC/form-actions em página dinâmica + sob concorrência, next 15.4.11) com gatilhos objetivos. +Alternativas rejeitadas: + B — investigar/upgrade dentro deste PR — estoura o appetite de um defect de + teste e mexe em dependência crítica sem dor medida em prod. +Gatilhos do débito: (1) relato de usuário/monitor vendo DOM duplo do wizard em + produção; (2) flakes de strict-mode persistirem em specs já gated pós-merge; + (3) upgrade de minor do Next tocado por outro motivo — validar então se a + cópia S: desaparece. +``` + +### Componentes / mudanças + +- **`tests/e2e/campaignRegisterDemand.e2e.spec.ts`**: (1) adicionar `waitForStreamSettled` ao import do fixture (L7); (2) inserir `await waitForStreamSettled(page)` imediatamente após o `page.goto` do test 1 (L26), antes das assertions do topBar; (3) reescrever o comentário de L38–39 citando o artefato S:/OPS83/#517 e nomeando os locators que permanecem sem escopo como rede de deteção. Trecho ilustrativo: + + ```ts + await page.goto(`/campanha/acoes/registrar-pedido?municipio=${municipality.slug}`) + // Prod-build streams a transient hidden `div[id^="S:*"]` copy of the whole + // wizard shell + form (OPS83/C106 artifact, #517) — wait it out before any + // strict-mode assertion. Kind/body/fill/submit below stay UNscoped on + // purpose: a genuinely duplicated form in prod must still fail here. + await waitForStreamSettled(page) + ``` + + Nenhum outro locator muda. Comentários em inglês (padrão do arquivo). + +- **`tests/e2e/fixtures/campaignE2EFixtures.ts`**: intocado — o helper já documenta o mecanismo; nada a acrescentar (diff mínimo pós-extração de hoje). +- **Issue de débito** (nova): raiz Next/streaming, gatilhos da Decisão 3. +- **Migration:** sem migration. **Access / Consent:** n/a. **UI:** n/a — zero product code. + +## Fases verificáveis + +1. **Spec** — editar `campaignRegisterDemand.e2e.spec.ts` (import + gate + comentário). Diff esperado: ~8 linhas em 1 arquivo. +2. **Smoke local em prod-mode** — `E2E_PROD=1 pnpm test:e2e --no-deps -- tests/e2e/campaignRegisterDemand.e2e.spec.ts` com `--repeat-each=3`. Passa isolado por natureza — serve só de sanidade de que o gate não quebrou o caminho feliz; a reprodução real (suíte cheia sob carga, prod-build) é coberta pelo CI do PR (curated/full via ci-scope). +3. **Débito** — abrir a issue da Decisão 3 com links para #659/#517 e o diag DOM (~15KB, action placeholder vs `$ACTION_REF_1`). +4. **Gates** — `pnpm gate:fast` (lint/typecheck/unit — pega import unused etc.). Pirâmide (OPS90): mudança e2e-only não exige unit/int novos; o diff classifica `selected` no CI. +5. **Changelog (OPS44/OPS85)** — escrever `docs/changelog/2026-08-24-b195-f1-wizard-s-gate.md` (uma entrada curta). **Não** rodar `pnpm changelog:build` nem commitar o agregado (gitignored, gerado sob demanda desde OPS85). +6. **Entrega** — commit inclui este `*-impl.md`; `pnpm push -u origin HEAD`; PR no GitHub (`scripts/github-pr.mjs`) base `main` com `Closes #659`; auto-merge nativo arma sozinho. + +## Rabbit holes / Não escopo (engenharia) + +- **Não** atacar a raiz no Next neste PR (upgrade, `loading.tsx`, forçar estático, PPR) — Decisão 3; débito com gatilho. +- **Não** reverter o dispatch manual C140 do `WizardStepFormChrome` (React 19 reseta campos não-controlados após form action assentada — o `
` sem `action attr` é correção real de produto). +- **Não** remover o `(app)/loading.tsx` (Suspense boundary do shell) nem forçar a rota a estático — quebraria o gate staff/B43 dinâmico. +- **Não** espalhar `.first()`/escopos pelos locators restantes — é a rede de deteção (aceite da intenção). +- **Não** migrar/tocar nos outros 5 specs nem no helper `waitForStreamSettled` (consolidação E2E-DEBT-S-GATE acabou de entregar; este é um consumidor novo, não refactor). +- **Não** criar unit/int para o comportamento — `waitForFunction` só existe em browser; o nível certo é o próprio e2e. +- **Não** "consertar" product code (render/hidratação do wizard) — componentes renderizam uma vez; a duplicação é artefato de SSR/streaming. + +## Riscos e mitigação + +- **Hipótese de equivalência entre o diag e o artefato S:** a duplicação observada (~15KB, duas árvores idênticas) é atribuída por forte evidência indireta à cópia `S:` (mesma página dinâmica, mesmo modo prod-only, mesma janela transitória), mas não há repro isolado. Mitigação: se o strict-mode violation voltar em CI **depois** do gate, o sinal é limpo (duplicação que sobrevive a 15s de poll = persistente) — escala direto para o débito da Decisão 3 com evidência fresca; nada foi mascarado. +- **Validação local fraca** (isolado passa por construção). Mitigação: smoke 3× + confiança no CI do PR que roda e2e curated/full em prod-build sob carga; monitorar o check-run do PR antes do merge. +- **Budget do gate (15s) insuficiente sob carga extrema** → falso negativo. Mitigação: 15s é o budget validado OPS83 para a classe; override por site via `{ timeout }` disponível no helper se medido. +- **Comentário novo envelhece** (artefato renomeado/mecanismo muda num upgrade futuro). Mitigação: comentário referencia #517/#659 e o helper, que é o ponto único de verdade do mecanismo. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto — spec estável sob o gate canônico; locators sem escopo preservam a falha em caso de duplicação genuína; zero product code; causa-raiz documentada (comentário + changelog + débito ligado a #517/OPS83) +- [x] Invariantes AGENTS/engineering-standards — sem schema/access/LGPD/URL; reuso do helper owner (sem twin, depth check); identifiers/comentários no padrão do repo +- [x] Testes de domínio previstos (unit/int) onde access/write paths mudam — n/a: nenhum access/write path muda; validação = e2e afetado + `pnpm gate:fast` (pirâmide OPS90) + +--- + +Self-score decision-quality: **5/5** — (1) decisões caras com rejeitadas (escopo do gate, destino do `.first()`, raiz Next); (2) cabe no appetite (1 arquivo de teste, ~8 linhas + issue de débito); (3) rabbit holes nomeados com gatilhos; (4) depth check reusa `waitForStreamSettled` (owner da classe de gate) sem criar paralelo; (5) intenção satisfeita sem reescrever o outcome — estabilidade com deteção de regressão intacta. diff --git a/tests/e2e/campaignRegisterDemand.e2e.spec.ts b/tests/e2e/campaignRegisterDemand.e2e.spec.ts index f83cf6da..c28019f8 100644 --- a/tests/e2e/campaignRegisterDemand.e2e.spec.ts +++ b/tests/e2e/campaignRegisterDemand.e2e.spec.ts @@ -4,7 +4,7 @@ import { CAMPAIGN_DEMAND_BODY_LABEL, CAMPAIGN_DEMAND_KIND_LABEL, } from '../../src/lib/schemas/campaignDemand.js' -import { expect, test } from './fixtures/campaignE2EFixtures.js' +import { expect, test, waitForStreamSettled } from './fixtures/campaignE2EFixtures.js' test.use({ viewport: { width: 390, height: 844 } }) @@ -24,6 +24,10 @@ test.describe('Wizard "Registrar pedido" (A5/B195)', () => { await campaign.login(page, coordinator.email!, coordinator.password) await page.goto(`/campanha/acoes/registrar-pedido?municipio=${municipality.slug}`) + // Prod-build streams a transient hidden `div[id^="S:*"]` copy of the whole + // wizard shell + form (OPS83/C106 artifact, #517; B195-F1 diag) — wait it + // out before any strict-mode assertion. + await waitForStreamSettled(page) const topBar = page.locator('[data-slot="campaign-mobile-top-bar"][data-mode="wizard"]') await expect(topBar.getByText('Registrar pedido', { exact: true })).toBeVisible() @@ -35,8 +39,10 @@ test.describe('Wizard "Registrar pedido" (A5/B195)', () => { // free-text field comes straight after Tipo and Atividade. await expect(page.getByRole('heading', { name: 'Registrar pedido' })).toHaveCount(0) await expect(page.getByLabel(CAMPAIGN_DEMAND_KIND_LABEL)).toBeVisible() - // `.first()`: transient RSC-pending duplication also copies the combobox - // trigger (same strict-mode flake as cd469857; observed on loaded machines). + // `.first()`: legacy guard from cd469857/D12 — before the S: settle gate, + // the streamed copy duplicated the whole shell, trigger included. The kind/ + // body/fill/submit locators below stay UNscoped on purpose: a genuinely + // duplicated form in prod must still fail here (#659). await expect(page.getByLabel(CAMPAIGN_DEMAND_ACTIVITY_LABEL).first()).toBeVisible() await expect(page.getByLabel(CAMPAIGN_DEMAND_BODY_LABEL)).toBeVisible() await expect(page.locator('input[name="title"]')).toHaveCount(0)