From 99a3a5f590695bf8aa39645dc0a8728a5688cc7e Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 12 Aug 2026 00:17:08 -0300 Subject: [PATCH] =?UTF-8?q?test(OPS42):=20e2e=20dev=20=E2=80=94=20quiesc?= =?UTF-8?q?=C3=AAncia=20do=20router=20antes=20de=20interagir=20(login=20+?= =?UTF-8?q?=20chat=20Sollinha)=20(Closes=20#691)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG-AGENTS.md | 1 + docs/plans/e2e-dev-router-quiescencia-impl.md | 79 +++++++++++++++++++ tests/e2e/campaignAiChatFollowUps.e2e.spec.ts | 8 +- .../campaignAiChatOpeningChips.e2e.spec.ts | 4 +- tests/e2e/campaignAiChatResize.e2e.spec.ts | 6 +- tests/e2e/campaignAiLinks.e2e.spec.ts | 4 +- tests/e2e/campaignAiTranscribe.e2e.spec.ts | 4 +- tests/e2e/campaignSollinhaContext.e2e.spec.ts | 12 +-- tests/e2e/fixtures/campaignE2EFixtures.ts | 40 +++++++++- 9 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 docs/plans/e2e-dev-router-quiescencia-impl.md diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 40be08ed..b707b474 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -10,6 +10,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-11):** **C115** (Issue #636) — **Google Calendar → Teqo**: quem tem permissão no calendário remarca/renomeia/cancela o compromisso direto no Google e a mudança volta para a atividade (bidirecional sobre o espelho C114). **Motor único bidirecional** no `runSyncPass` do C114 (estende o dono): para cada evento nosso — conteúdo difere? → **regra do relógio**: Google mais novo aplica **título + horário** (all-day incluído; prefixo `[Município] ` só é destripado quando bate com o município — desfazemos só a nossa prefixação; cap 160) com **registro auditável** no `updates` feed (`Google Calendar: …`, `author: null`, feed mostra "Google Calendar" como autor pelo prefixo) — senão o Teqo re-asserta. **Baseline do relógio = `activity.lastMirroredChangeAt`** (novo campo system-write, carimbado no beforeChange só quando um campo espelhado muda): task toggle/update/resultado bumpam `updatedAt` sem tocar o espelho — comparar contra `updatedAt` silenciaria uma edição pendente do Google. **Escrita reversa sem usuário** (`req.user` stripped): staff que dispara a passada não ganha autoria do registro nem passa pelo gate de re-agendamento do deputado. **Cancelamento:** `listEvents` com `showDeleted=true` — evento `status: 'cancelled'` (lixeira) com atividade `confirmado` → cancela **se o cancelamento for mais novo que o último carimbo** (senão é reopen do staff: o trashed é limpo e o evento recriado — nunca re-cancela); `realizado` é histórico protegido. **Remoção permanente** distinguida de "nunca criado" por snapshot `lastSeenEventIds` (JSONB `{calendarId, ids}`, gravado só em passada bem-sucedida e **excluindo ids que o próprio motor deletou** — senão o reopen virava re-cancelamento fantasma); troca de `calendarId` → snapshot stale → recria sem cancelar. **Webhook público** `POST /campanha/agenda/google-webhook/[secret]` (fora de `(app)`, precedente `ical/[secret]`, `maxDuration=60`, `force-dynamic`, allowlist do `campaignJsonMutationRoute` com razão): pre-filter barato (secret ≥32 + headers) ANTES do bootstrap do Payload + validação fail-closed em lib pura testada (`isGooglePushNotificationValid` — secret da URL + `X-Goog-Channel-ID` + `X-Goog-Resource-ID` + `X-Goog-Channel-Token`, constant-time, 404; `not_exists` → estado de erro; `sync` → ignora); ping sem payload → passada completa; 200 mesmo se a passada falhar (sem retry storm). **Canal lazy sem cron (Hobby):** `ensureGoogleCalendarPushChannel` em toda passada — watch com UUID novo, TTL 30d, **segredo rotaciona a cada criação** (URL vazada auto-cura na renovação), **novo canal persistido ANTES do stop do antigo** (falha do stop não orfana o novo); expira em <48h → watch com id novo + `channels.stop` do antigo; troca de calendário → re-watch; falha → `pushChannelError` (state), nunca pausa (o reverso roda em todo trigger local; canal só dá imediatismo — limitação de 30d de silêncio documentada). Campos novos na `googleCalendarSync` (system-write; **identity fields com `read: false`** — o conjunto de credenciais do webhook nunca sai por path de usuário; create admin-only + leitura prefere linha configurada); migration **hand-written** (mesmo motivo do C114). **Título editável pelo Google** exigiu `mutationKind: 'googleCalendarSync'` dedicado (título muda, slug canônico congelado) + **skip do hook de sync na própria escrita reversa** (sem passada aninhada). UI (Impeccable B): seção "Edições pelo Google" no dialog (copy + expiração do canal + erro). Testes: unit (reverse mapping 18, validação do webhook 7, dialog/feed render 6, allowlist), int (13 novos no motor com stub: Google novo aplica+converge, task toggle não silencia edição pendente, Teqo novo re-asserta, cancelled cancela+limpa, reopen pós-cancelamento não re-cancela, remoção permanente via snapshot, create falhou não cancela, troca de calendário não cancela+re-watch, all-day ida-e-volta, realizado protegido, description-only sem registro, ciclo do canal cria/renova/rotaciona/stop, falha de canal não pausa — asserções herméticas a atividades ambiente de arquivos irmãos no DB compartilhado), e2e (webhook 404 fail-closed + caminhos 200 validados: sync/not_exists/change). Suíte completa verde (unit 2064, int 749, e2e 5/5). Intenção [`c115-google-calendar-edicao-bidirecional.md`](plans/c115-google-calendar-edicao-bidirecional.md); impl [`c115-google-calendar-edicao-bidirecional-impl.md`](plans/c115-google-calendar-edicao-bidirecional-impl.md). **Recently resolved (2026-08-11):** **C129** (Issue #696) — dobradinha ganha **nome de legenda** (campo + display discreto sob o nome): campo `ballotName` (texto opcional, `maxLength: 30` — limite real do nome de urna do TSE; **sem index** — nunca é chave de busca/ordenação/facet, o nome real continua sendo a chave) em `stateDeputy`, ao lado de `party`. **Migration** `20260811_234822_add_state_deputy_ballot_name` (gerada; o diff do gerador re-emitia um no-op de `ADD VALUE 'lembranca'` por snapshot stale — limpa à mão para só o `ADD COLUMN`; nullable, sem backfill). **Display "discreto sob o nome"** (mesma família, `text-xs text-muted-foreground`, sem label — a posição é o rótulo) em 3 pontos: 2ª linha da célula de Nome de `/campanha/pessoas` via seam pura `peopleNameSubline` em `peopleData` (C130 estende com o fallback `?? city` — regra combinada final legenda-sobrepõe-base registrada nos dois planos; nesta branch, sem legenda nada muda na linha, aceite da intenção), sublinha fora da região click-to-edit da célula de Nome na lista de Dobradinhas (irmã da célula no flex-col — clicar nela nunca abre o editor do nome), e sublinha no Nome da seção Contato da ficha. **Edição** (onde já se edita dado de dobradinha, B163): coluna "Nome de legenda" na lista de Dobradinhas (mesma máquina de célula do partido, `editTrigger="cell"`, **default-hidden** em `DEFAULT_HIDDEN_COLUMN_IDS.dobradinhas` — precedente E-mail B197; a sublinha é o display always-on e a coluna o opt-in de edição) + campo no `StateDeputyForm` (criar e editar; schemas create/update/inline `trimmedOptionalText(30)`/`trimmedNullableText(30)` — vazio limpa o campo). Escrita: `updateStateDeputyBallotNameRecord` espelhando o partido (`runStaffEntityMutation`, staff-only, sem transação/locks/Consent novos — escrita single-collection); `CampaignInlineEditableField` ganhou `'ballotName'` (aditivo). Sem migration destrutiva/access/Consent. Testes: unit (merge carrega `ballotName`; `peopleNameSubline` legenda-only/null; default de colunas dobradinhas `['email','ballotName']`), int novo `campaignStateDeputyBallotName` (staff seta/limpa, advisor ok, leader negado, criação com legenda); verificado ao vivo em dev (pessoas/dobradinhas/ficha, edição inline persistindo na sublinha). Gates: unit 2062, int 750, build OK; knip = P3 ledgered pré-existente (idêntico no baseline). Intenção [`pessoas-nome-de-legenda-dobradinha.md`](plans/pessoas-nome-de-legenda-dobradinha.md); impl [`pessoas-nome-de-legenda-dobradinha-impl.md`](plans/pessoas-nome-de-legenda-dobradinha-impl.md). (feat(C129): nome de legenda da dobradinha — campo ballotName + display discreto sob o nome (Closes #696)) +**Recently resolved (2026-08-11):** **OPS42** (Issue #691) — o e2e dev não perde mais fill/Enter para a **navegação RSC tardia do login**: em dev sob carga, ~150–500 ms após o load do `/campanha` o router do Next dev dispara um `history.replaceState` sem request (framenav) e remonta o subtree do layout (provider do chat novo, textarea novo com `defaultValue=''`) — interações imediatas caem na janela e são silenciosamente perdidas (input volta a `''`, envio nunca dispara; CI/prod imune — main verde). **Mecanismo medido por sonda** (3 runs nesta máquina, load 45–61): o `waitForURL` do login resolve **no commit** do redirect (não otimista — a hipótese da intenção caiu), e o commit perigoso é um **segundo** framenav pós-`goto` do próprio teste, cuja remontagem do DOM chega 150–250 ms depois; interação dentro da janela ainda induz cascade do overlay de dev (`POST /__nextjs_original-stack-frames`); fill atrasado ~2 s sobrevive. **Fix:** helper `waitForRouterSettled(page)` no fixture (`campaignE2EFixtures.ts`, padrão `checkRadixWhenHydrated`) — retorna após janela de **500 ms sem `framenavigated`** (fail-soft em 15 s com `console.warn`; **no-op em prod** via `CI`/`E2E_PROD` — custo zero no CI), chamado no `campaign.login` (pós-`waitForURL`) e nos pontos de interação imediata da família do chat: `openChatAndSend`/`openChat`/`askViaInput` em `campaignSollinhaContext`, `campaignAiChatResize`, `campaignAiLinks`, `campaignAiTranscribe`, `campaignAiChatFollowUps`; `openMobileDrawer` e os 2 cliques de drawer diretos de `campaignAiChatFollowUps`/`campaignAiChatOpeningChips` + o "Fechar" desktop do `campaignAiChatResize` (o run 3 pegou o clique do drawer na mesma janela — a classe é qualquer interação no layout, não só fill/Enter). **Rejeitadas:** retry de interação (double-send com o mock gated do B199), só-no-login (não enxerga o commit pós-goto), networkidle/wrap global de goto. **Evidência:** `campaignSollinhaContext` 10/10 ×3 dev (load 31–61; o B188 "reload na mesma aba" que flakeava 5/5 na sessão B199) + 8/8 prod (`E2E_PROD=1`, 30,7 s — no-op); irmão `campaignAiChatResize` 8/8 dev. Gates: tsc/lint/format/cycles verdes (knip falha pré-existente P3 — payload.config). Sem migration/access/Consent/UI/app. Intenção [`e2e-dev-router-quiescencia.md`](plans/e2e-dev-router-quiescencia.md); impl [`e2e-dev-router-quiescencia-impl.md`](plans/e2e-dev-router-quiescencia-impl.md). **Recently resolved (2026-08-11):** **C123** (Issue #667) — a agenda ganha **uma superfície única** de criação/edição de atividade: o overlay (modal central no desktop, drawer do C103 no mobile) com **todas** as seções do formulário completo; as páginas `/campanha/atividades/nova` e `/[slug]/editar` saem **sem redirecionamento** (404). `ActivityInlineCreate` virou `ActivityOverlay` (`ActivityOverlay.tsx`): slot click → criação com o intervalo do slot; **event click → edição** (Google Calendar; o detalhe fica acessível pelo link "Ver detalhes" no rodapé do overlay; middle-click no evento ainda navega ao detalhe via `url` preservada); **`Local` renderizado uma única vez** (o bloco duplicado do popover C91 morreu); Início/Término com **data e hora separados e visíveis sem rolar** (`ActivityDateTimeField` reescrito: trigger de data em popover/bottom-sheet + selects Hora/Minuto inline; contrato civil `YYYY-MM-DDTHH:mm` intacto; mobile `[dia][hora][minuto]` por linha). **Submit por FormData nas actions `createActivityOverlay`/`updateActivityOverlay`** (reusam os parsers `parseActivity*FormData` + os `*Record` transacionais; `status` só na criação — na edição não envia, evita realizado→confirmado) e **`loadActivityEditDraft`** (findByID `activityFormSelect`, `overrideAccess: false`, int testado por escopo). **Bug latente C14 consertado:** o `` que o update perdia (o `/editar` estava quebrado desde `adc29c03`) volta no modo edição. Quick actions `new-activity`/`edit-activity` viraram `onAction` bridged por contexto (`openActivityCreate`/`openActivityEdit`, precedente C94/C114); hosts novos: `ActivityCreateOverlayHost` (lista `/atividades`) e `ActivityEditOverlayHost` (detalhe, botão Editar + FAB). Morrem: páginas/formActions/`ActivityForm.tsx`, `buildActivityCreateHref`/`parseActivityCreatePrefill`/`parseActivityAgendaReturnHref` (activityUi), `atividadesNova` (chrome catalog), `ACTIVITY_NEW_PATH`, `getActivityEditPageData`, `createActivityInline` (vira `createActivityOverlay`), `activityInlineErrors` (vira `activityOverlayErrors`, com duplicate-demand + boundary/zod). Sem migration/access/Consent. Testes: unit overlay 14 (create/edit/erro/sheet), unit mappers/Ui/quickActions/agendaInteractions (eventClick→edit), int `loadActivityEditDraftRecord` por papel, e2e `campaignActivity` 7/7 + agenda mobile/feed/google 9/9 no modo prod (dev-mode flakeou por cold-compile do dev server sob carga da máquina — prewarm 180s estourou; `E2E_PROD=1` é o modo de verificação). Intenção [`agenda-criacao-modal-central.md`](plans/agenda-criacao-modal-central.md); impl [`agenda-criacao-modal-central-impl.md`](plans/agenda-criacao-modal-central-impl.md). **Recently resolved (2026-08-11):** **C126** (Issue #686) — os int specs do espelho Google (C114) deixam de flakear sob a suíte paralela: o motor ganhou **escopo injetável** `activityWhere?: Where` em `CampaignCalendarSyncOptions` (`src/utilities/googleCalendarSync.ts`) — appendado ao `and` do push set (`loadSyncActivities`) **e** do delete-guard set (`loadAliveActivityIds`); produção (hooks + action) omite o param → espelho cheio byte-identical. A race era mais ampla que o par da Issue: **5** specs criam atividades in-window no banco compartilhado (`campaignActivity`, `calendarFeed`, `homeSearchActivities`, `municipalityDossierData` + `campaignSuggestions` com `realizado` no lookback — o 5º que a Issue não nomeou), e `outcome.created` global contava todas; serialização não era construtível (vitest não serializa par-a-par; `fileParallelism:false` mataria o paralelismo da suíte) e "nunca relaxar a asserção" foi mantido à letra. O spec roda cada pass via helper `runSync(client, reason)` com `activityWhere: { title: { like: 'C114%' } }` (mesmo filtro do afterEach) e ganhou **dois pins**: (1) atividade estrangeira in-window **não** é espelhada; (2) o delete-guard honra o escopo (evento de atividade alheia viva é removido — espelho escopado é autoritativo no seu escopo). **Conflito com o C122 (#705, mergeado antes):** o C122 endereçou a C126 pela abordagem oposta (asserções globais **removidas** em favor de scoping por event-id, com delegação explícita do desfecho: "quem a detém decide") — a resolução do rebase mantém o contrato da Issue (contagens globais restauradas e determinísticas via escopo) e absorve a infra do C122 (credential compartilhada `googleCalendarTestKey`, cleanup de config por owned id, datas C127 derivadas do agora). **Contraprova:** fix 3/3 rodadas × 5 arquivos verdes (62/62); motor antigo re-flakeia 3–5 testes/rodada nas mesmas condições, e o pin 1 falha 100% — regressão viva. Gates: unit 2050, int 747, lint/typecheck/format/knip/cycles/build verdes. Sem migration/access/Consent/UI. Débito C127/#688 (datas hardcoded) já resolvido pelo C122 (#705). Impl [`c126-espelho-google-int-flake-isolamento-impl.md`](plans/c126-espelho-google-int-flake-isolamento-impl.md) (Issue = spec, sem intenção própria). diff --git a/docs/plans/e2e-dev-router-quiescencia-impl.md b/docs/plans/e2e-dev-router-quiescencia-impl.md new file mode 100644 index 00000000..83c4fc4c --- /dev/null +++ b/docs/plans/e2e-dev-router-quiescencia-impl.md @@ -0,0 +1,79 @@ +# Impl: e2e dev — navegação RSC do login commita tarde e remonta o layout (interação silenciosamente perdida) + +Status: implementado (evidência coletada 2026-08-11 — aceite ×3 dev + ×1 prod) +Atualizado em: 2026-08-11 +Issue: #691 +Intenção: docs/plans/e2e-dev-router-quiescencia.md +Appetite restante: herdado (~0,5 dia eng — um helper de "quiescência do router" no fixture) + +## Leitura da intenção + +- **Outcome:** em dev mode, interagir com a página nunca mais perde o fill/Enter para uma remontagem tardia do layout; prod mode (CI) intacto. +- **O que NÃO negociar:** nenhuma asserção de produto muda; app não muda; nada de `networkidle` mágico; helper dev-only ou inócuo em prod; precedentes `checkRadixWhenHydrated` (retry até o estado certo) e OPS30 (resiliência ao compile frio no fixture). +- **O que reavaliar:** a hipótese "o `waitForURL` resolve no redirect otimista antes do commit RSC". Medido com sonda nesta máquina (load 45, 2026-08-11): o `waitForURL` resolve **no commit** (o pushState/replaceState só ocorre quando a payload RSC termina de renderizar), e o commit perigoso não é o do redirect — é um **segundo** framenav que dispara **após o `goto` do próprio teste** (~150–500 ms depois do load), sem request nenhum (`history.replaceState('/campanha')`), cujo commit React remonta o layout 150–250 ms depois do framenav. Consequência: consertar só o `login` do fixture **não** protege o fluxo `login → goto → interagir` (o caso do B188). + +## Abordagem recomendada + +```mermaid +flowchart LR + A[waitForRouterSettled\nno fixture: janela sem framenav,\ndev-only, fail-soft] --> B[call no campaign.login\napós waitForURL] + A --> C[call nos helpers de interação\ndo chat após o HELLO] + B --> D[verificação dev ×3\ncampaignSollinhaContext] + C --> D + D --> E[verificação prod ×1\nE2E_PROD=1] + E --> F[CHANGELOG + plan commitados,\nPR Ready + Closes #691] +``` + +**Opções consideradas:** A | B | C | D +**Recomendação:** **A** — helper `waitForRouterSettled(page)` no fixture (padrão `checkRadixWhenHydrated`), chamado **no `campaign.login`** (cobre o modelo "waitForURL otimista" e interações diretas pós-login) **e nos helpers de abertura do chat** (`openChatAndSend`/`openChat`/`askViaInput` dos 5 specs da família Sollinha, após o wait do HELLO — o único ponto onde o journey interage imediatamente após o load). A janela é **ausência de `framenavigated`** (500 ms, 2× a maior defasagem observada framenav→remontagem: 158–252 ms), não network idle. +**Rejeitadas:** B (retry da interação tipo "re-fill até a resposta aparecer") porque re-send duplicaria com o mock gated do B199 (que conta requests) e muda a semântica de cada interação; C (só no `login`) porque a evidência mostra que a remontagem perigosa dispara **depois** do `goto` do teste — o login não a enxerga; D (espera `networkidle` ou wrap global de `page.goto`) porque a intenção corta networkidle (HMR nunca fica idle em dev) e o wrap global paga em todo goto de toda spec. + +### Componentes / mudanças + +- **`waitForRouterSettled`** (`tests/e2e/fixtures/campaignE2EFixtures.ts`, ao lado de `checkRadixWhenHydrated`): escuta `framenavigated`; retorna quando `now - lastNav > 500 ms` (`expect(...).toPass({ timeout: 15_000 })`); **no-op em prod** (`process.env.CI || process.env.E2E_PROD === '1'` — mesma detecção do `playwright.config.ts`); **fail-soft no timeout** (router que nunca quiesce sob HMR extremo não mascara a falha real — a asserção seguinte carrega o sinal; corte do rabbit hole da intenção). +- **`campaign.login`** (mesmo arquivo): `await waitForRouterSettled(page)` após o `waitForURL`. +- **Helpers dos specs** (após o `expect(HELLO).toBeVisible`): `campaignSollinhaContext.e2e.spec.ts` (`openChatAndSend` — o teste B188 que flakeia; e `openMobileDrawer` — ajuste pós-gate, ver abaixo), `campaignAiChatResize.e2e.spec.ts` (`openChatAndSend`), `campaignAiLinks.e2e.spec.ts` (`openChatAndSend`), `campaignAiTranscribe.e2e.spec.ts` (`openChat`), `campaignAiChatFollowUps.e2e.spec.ts` (`askViaInput` — o retry 60 s atual é o fallback caro que o settle evita). `campaignAiChatOpeningChips` e `campaignSollinhaWidth` não interagem com fill logo após o load (chips já têm retry via `toPass`) — fora de escopo. +- **Migration:** sem migration. **Access / Consent:** sem mudança (test-infra). **UI:** Impeccable A — N/A, infra de testes. +- **Sonda temporária** `tests/e2e/campaignProbe.e2e.spec.ts` (evidência) — **deletada** antes do PR. + +### Ajuste pós-gate (evidência de execução, 2026-08-11 — mesma sessão) + +O run 3 do aceite pegou o **clique do drawer** na mesma janela de remontagem: o B198 "link interno" falhou com o drawer nunca abrindo (snapshot: botão ativo, dialog ausente — click silent no-op), load 61 no momento. A classe não é só fill/Enter — é **qualquer interação no layout** dentro da janela pós-load. `openMobileDrawer` do `campaignSollinhaContext` ganhou o mesmo settle antes do clique (1 linha, mesmo mecanismo); re-run do arquivo completo verde 10/10. O clique do FAB do B199 já era retried via `toPass` (padrão B13/B17) — intocado. + +### Evidência (2026-08-11, máquina com worktrees paralelos, load 31–61) + +- **Aceite dev — `campaignSollinhaContext` 10/10 ×3 consecutivos** (runs 23:04–23:26, load 31–61; inclui o B188 "reload na mesma aba" que flakeava 5/5 na sessão B199). O run 3 falhou 1 (clique do drawer do B198 na janela — ajuste pós-gate acima) e re-rodou 10/10 com o settle no `openMobileDrawer`. +- **Irmão da família — `campaignAiChatResize` 8/8** em dev (load 36–48). +- **Prod — `campaignSollinhaContext` 8/8 com `E2E_PROD=1`** (build `NEXT_DIST_DIR=.next/e2e`; 30,7 s total — helper no-op confirmado). +- **Sonda (deletada):** 3 runs de sonda — bug reproduzido 2× antes do fix (fill+Enter → `input-value=""`, `probe-sent=false`; cascade do overlay de dev no run 2) e 1× sobreviveu com fill atrasado ~2 s; sinais: 2× `REQ /campanha?_rsc=1fr2x rsc=1` → commit (`FRAMENAV`+`waitForURL` mesmo ms) → `goto` → **`replace /campanha` sem request ~150–500 ms após HELLO** → textarea novo 158–252 ms depois. + +## Fases verificáveis + +1. **Fixture + helper (tracer)** — implementar `waitForRouterSettled` + call no `login`; `pnpm exec tsc --noEmit` + `pnpm exec knip`. +2. **Call sites nos specs** — os 5 helpers; `pnpm format` + lint. +3. **Evidência dev (aceite)** — `campaignSollinhaContext.e2e.spec.ts` **×3 consecutivos** em dev a 2 workers (inclui o B188 "reload na mesma aba", o teste que flakeia 5/5 na sessão B199); + 1× `campaignAiChatResize` (irmão da família). Registrar load average junto. +4. **Evidência prod** — `campaignSollinhaContext.e2e.spec.ts` **1× com `E2E_PROD=1`** (no-op do helper; CI imune por construção — confirma). +5. **Docs + limpeza** — deletar `campaignProbe.e2e.spec.ts`; entrada curta no `docs/CHANGELOG-AGENTS.md`; status do impl plan `em execução` → fechado na entrega. +6. **Gates + push** — `pnpm gate:fast`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`; `pnpm push` → PR Ready `--base main` + `Closes #691`. + +## Rabbit holes / Não escopo (engenharia) + +- Esperar `networkidle` — cortado na intenção (HMR nunca quiesce em dev). +- Wrap global de `page.goto` — paga em todo goto de toda spec; o corte da intenção é "só onde interage imediatamente após o load". +- Consertar a remontagem do Next dev em si — fora de escopo da intenção. +- Retry de interação (re-send) — risco de double-send com o mock gated do B199; o settle elimina a causa. +- Ressuscitar o retry de login do OPS30 (removido no OPS36 pelo session-seeding) — classes diferentes; o settle não reabre aquela decisão. + +## Riscos e mitigação + +- **Janela de quiescência curta demais sob carga extrema** (defasagem framenav→remontagem > 500 ms): observado máx. 252 ms; o `toPass` esparso (~100/350/850 ms) efetivamente alarga a janela; se reaparecer flake, subir a janela (constante com um número — mudança de 1 linha, gatilho de revisitação registrado). +- **Helper mascara falha real:** timeout fail-soft — a asserção seguinte do journey continua sendo o sinal honesto. +- **Custo em dev:** ~0,5–1 s por login de browser e por interação de chat (a janela de 500 ms + a cadência esparsa do `toPass`; medido ~850 ms por call site); zero em prod (no-op). +- **Custo em CI:** zero (no-op por `process.env.CI`). + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (nenhuma asserção de produto tocada; app intacto) +- [x] Invariantes AGENTS/engineering-standards (sem migration/access/Consent; fixtures test-infra) +- [x] Testes de domínio previstos: gate:fast + e2e conforme evidência (×3 dev + ×1 prod) +- [x] Self-score decision-quality: 5/5 (opções rejeitadas registradas; evidência de sonda antes da decisão; depth check: reusa o fixture único de helpers; sem módulo novo) diff --git a/tests/e2e/campaignAiChatFollowUps.e2e.spec.ts b/tests/e2e/campaignAiChatFollowUps.e2e.spec.ts index eff610f1..c9ca374d 100644 --- a/tests/e2e/campaignAiChatFollowUps.e2e.spec.ts +++ b/tests/e2e/campaignAiChatFollowUps.e2e.spec.ts @@ -1,6 +1,6 @@ import type { Page } from '@playwright/test' -import { expect, test } from './fixtures/campaignE2EFixtures.js' +import { expect, test, waitForRouterSettled } from './fixtures/campaignE2EFixtures.js' /** * Mock the AI endpoint with SDK v7 UI-message-stream SSE chunks (start / @@ -79,6 +79,10 @@ const pickChip = async (page: Page, text: string) => { } const askViaInput = async (page: Page, text: string, expected: string) => { + // OPS42 — dev-mode router churn can remount the chat after load; only + // interact once no framenav is pending (dev-only no-op). The retry below is + // the expensive fallback the settle avoids. + await waitForRouterSettled(page) const input = page.getByPlaceholder('Pergunte para o Sollinha...') // Dev-mode defense (same as campaignAiLinks): a first-hit route compile can // trigger a full-page reload that wipes the draft — re-send until the answer @@ -158,6 +162,8 @@ test.describe('B192 — follow-ups sugeridos após cada resposta do Sollinha', ( await page.goto('/campanha') const drawer = page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' }) + // OPS42 — dev-only settle before the click (see `waitForRouterSettled`). + await waitForRouterSettled(page) await page.getByRole('button', { name: 'Sollinha — Assistente virtual' }).click() await expect(drawer).toBeVisible({ timeout: 20_000 }) diff --git a/tests/e2e/campaignAiChatOpeningChips.e2e.spec.ts b/tests/e2e/campaignAiChatOpeningChips.e2e.spec.ts index c1230977..a9d3529c 100644 --- a/tests/e2e/campaignAiChatOpeningChips.e2e.spec.ts +++ b/tests/e2e/campaignAiChatOpeningChips.e2e.spec.ts @@ -1,6 +1,6 @@ import type { Page } from '@playwright/test' -import { expect, test } from './fixtures/campaignE2EFixtures.js' +import { expect, test, waitForRouterSettled } from './fixtures/campaignE2EFixtures.js' /** * Mock the AI endpoint with a minimal SSE stream so sending a chip neither @@ -91,6 +91,8 @@ test.describe('B191 — ações rápidas de abertura no chat Sollinha (chips de await page.goto('/campanha') const drawer = page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' }) + // OPS42 — dev-only settle before the click (see `waitForRouterSettled`). + await waitForRouterSettled(page) await page.getByRole('button', { name: 'Sollinha — Assistente virtual' }).click() await expect(drawer).toBeVisible({ timeout: 20_000 }) await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) diff --git a/tests/e2e/campaignAiChatResize.e2e.spec.ts b/tests/e2e/campaignAiChatResize.e2e.spec.ts index 46ad2820..980eff97 100644 --- a/tests/e2e/campaignAiChatResize.e2e.spec.ts +++ b/tests/e2e/campaignAiChatResize.e2e.spec.ts @@ -1,6 +1,6 @@ import type { Page } from '@playwright/test' -import { expect, test } from './fixtures/campaignE2EFixtures.js' +import { expect, test, waitForRouterSettled } from './fixtures/campaignE2EFixtures.js' /** * Mock the AI endpoint with a minimal SSE stream so sending a message neither @@ -29,6 +29,8 @@ const MESSAGE = 'Mensagem que migra entre as superficies' const openChatAndSend = async (page: Page) => { await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) + // OPS42 — dev-only settle before interacting (see `waitForRouterSettled`). + await waitForRouterSettled(page) const input = page.getByRole('textbox', { name: 'Pergunte para o Sollinha...' }) await input.fill(MESSAGE) await input.press('Enter') @@ -121,6 +123,8 @@ test.describe('B167 — chat Sollinha migra entre painel e drawer ao redimension await page.goto('/campanha') // Desktop opens the chat at load (as before); close it so it is "fechado". + // OPS42 — dev-only settle before the click (see `waitForRouterSettled`). + await waitForRouterSettled(page) await page .getByRole('button', { name: 'Fechar', exact: true }) .filter({ visible: true }) diff --git a/tests/e2e/campaignAiLinks.e2e.spec.ts b/tests/e2e/campaignAiLinks.e2e.spec.ts index 5855baca..8e5c6f07 100644 --- a/tests/e2e/campaignAiLinks.e2e.spec.ts +++ b/tests/e2e/campaignAiLinks.e2e.spec.ts @@ -1,6 +1,6 @@ import type { Page } from '@playwright/test' -import { expect, test } from './fixtures/campaignE2EFixtures.js' +import { expect, test, waitForRouterSettled } from './fixtures/campaignE2EFixtures.js' /** * B187 — links in the Sollinha's markdown answers must look like links: brand @@ -34,6 +34,8 @@ const mockAiChat = (page: Page) => const openChatAndSend = async (page: Page) => { await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) + // OPS42 — dev-only settle before interacting (see `waitForRouterSettled`). + await waitForRouterSettled(page) const input = page.getByRole('textbox', { name: 'Pergunte para o Sollinha...' }) // Dev-mode defense: a first-hit route compile can trigger a full-page reload // that wipes the just-typed draft mid-test (production/CI never does), so diff --git a/tests/e2e/campaignAiTranscribe.e2e.spec.ts b/tests/e2e/campaignAiTranscribe.e2e.spec.ts index a58edfe3..329d09c4 100644 --- a/tests/e2e/campaignAiTranscribe.e2e.spec.ts +++ b/tests/e2e/campaignAiTranscribe.e2e.spec.ts @@ -1,6 +1,6 @@ import type { Page } from '@playwright/test' -import { expect, test } from './fixtures/campaignE2EFixtures.js' +import { expect, test, waitForRouterSettled } from './fixtures/campaignE2EFixtures.js' /** * B173 — voice transcription for the Sollinha chat. @@ -58,6 +58,8 @@ const mockAiChat = (page: Page) => const openChat = async (page: Page) => { await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) + // OPS42 — dev-only settle before interacting (see `waitForRouterSettled`). + await waitForRouterSettled(page) } test.describe('B173 — consulta por voz no chat da Sollinha', () => { diff --git a/tests/e2e/campaignSollinhaContext.e2e.spec.ts b/tests/e2e/campaignSollinhaContext.e2e.spec.ts index a324617d..860e9790 100644 --- a/tests/e2e/campaignSollinhaContext.e2e.spec.ts +++ b/tests/e2e/campaignSollinhaContext.e2e.spec.ts @@ -1,6 +1,6 @@ import type { Page } from '@playwright/test' -import { expect, test } from './fixtures/campaignE2EFixtures.js' +import { expect, test, waitForRouterSettled } from './fixtures/campaignE2EFixtures.js' const SESSION_KEY = 'teqo:campaign:sollinha-chat-session' @@ -76,6 +76,8 @@ const SECOND_MESSAGE = 'Segunda pergunta' const openChatAndSend = async (page: Page) => { await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) + // OPS42 — dev-only settle before interacting (see `waitForRouterSettled`). + await waitForRouterSettled(page) const input = page.getByRole('textbox', { name: 'Pergunte para o Sollinha...' }) await input.fill(MESSAGE) await input.press('Enter') @@ -92,6 +94,9 @@ const waitForChatSettled = async (page: Page) => { /** Opens the mobile drawer and waits for it to cover the page. */ const openMobileDrawer = async (page: Page) => { const drawer = page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' }) + // OPS42 — the trigger is part of the layout that dev-mode router churn can + // remount after load; a click in that window is a silent no-op (dev-only). + await waitForRouterSettled(page) await page.getByRole('button', { name: 'Sollinha — Assistente virtual' }).click() await expect(drawer).toBeVisible({ timeout: 20_000 }) return drawer @@ -166,10 +171,7 @@ test.describe('B188 — contexto da conversa persiste na sessão da janela/tab', await page.goto('/campanha') await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0) - await page.getByRole('button', { name: 'Sollinha — Assistente virtual' }).click() - await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toBeVisible({ - timeout: 20_000, - }) + await openMobileDrawer(page) // The open flag must have been persisted before reload. await expect.poll(async () => (await storedSession(page))?.open).toBe(true) diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index 7a4f42b6..f301373e 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { sql } from '@payloadcms/db-postgres' -import type { BrowserContext, Fixtures, Page } from '@playwright/test' +import type { BrowserContext, Fixtures, Frame, Page } from '@playwright/test' import { getPayload, type CollectionSlug, type Payload, type PayloadRequest } from 'payload' import { municipalityCatalog } from '../../../src/lib/municipalityCatalog.js' @@ -505,6 +505,39 @@ export const checkRadixWhenHydrated = async (page: Page, label: string) => { }).toPass({ timeout: 15_000 }) } +/** + * OPS42 — waits out a pending dev-mode router navigation before the test + * interacts. Next dev's hydration/router churn can commit a layout remount + * (new chat provider, new textarea with `defaultValue=''`) ~150–500 ms AFTER + * the page load — measured 2026-08-11 under load — so a fill/Enter landing in + * that window is silently lost. The commit is preceded by a `framenavigated` + * (a bare `history.replaceState`, no request), so the helper returns only + * after a quiet window with no framenav. Dev-only: production builds have no + * dev compile churn, and CI runs the prod build — the wait is a no-op there. + * Fail-soft on timeout: a router that never quiesces (HMR churn under extreme + * load) must not mask a real app failure — the next assertion carries it. + */ +export const waitForRouterSettled = async (page: Page): Promise => { + const quietWindowMs = 500 + const timeoutMs = 15_000 + if (process.env.CI || process.env.E2E_PROD === '1') return + let lastNavigationAt = Date.now() + const onNavigation = (frame: Frame) => { + if (frame !== page.mainFrame()) return + lastNavigationAt = Date.now() + } + page.on('framenavigated', onNavigation) + try { + await expect(async () => { + expect(Date.now() - lastNavigationAt).toBeGreaterThan(quietWindowMs) + }).toPass({ timeout: timeoutMs }) + } catch { + console.warn('waitForRouterSettled: router did not quiet down in time — proceeding.') + } finally { + page.off('framenavigated', onNavigation) + } +} + /** * Miss #53: the app's biometrics probe is ONE-SHOT per island mount * (`useCampaignBiometricsAvailable`) — if @@ -658,6 +691,11 @@ export const campaignFixture: CampaignE2EFixtureValue = async ({}, runFixture) = await page.waitForURL(dashboardURL, { timeout: 30_000 }) } } + // OPS42 — dev-only quiescence: when the URL change lands before the + // redirect's RSC payload commits (observed under load), the layout can + // remount right after login — wait for the router to quiet down + // before the test interacts (no-op in prod; see `waitForRouterSettled`). + await waitForRouterSettled(page) }, payload: fixtures.payload, sessionFor: async (context, user) => {