diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 33299df5..45b23349 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -208,11 +208,15 @@ jobs: run: pnpm exec playwright install --with-deps chromium - name: Build for e2e # Selected mode needs the build too (the Playwright webServer serves - # the `.next-e2e` production build). + # the `.next-e2e` production build). NEXT_PUBLIC_SITE_URL is inlined + # at build time, so the canonical `.test` origin must reach THIS build + # — it lets the agenda feed dialog generate a link under the + # production build when AgendaFeed is in the blast radius (OPS83). if: steps.scope.outputs.e2e_mode == 'selected' run: pnpm build env: NEXT_DIST_DIR: .next-e2e + NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test - name: E2E tests (blast radius — manifest-selected specs) if: steps.scope.outputs.e2e_mode == 'selected' # OPS72: the PR never runs e2e full — mode `full` (high-risk diff) @@ -221,6 +225,7 @@ jobs: # 4 workers in one process replace the former 2 shards × 2 workers. env: PLAYWRIGHT_WORKERS: 4 + NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test # The `--` separates pnpm from the scope paths: pnpm consumes it and # playwright receives the bare spec paths (no flag parsing drift). run: pnpm test:e2e -- ${{ steps.scope.outputs.e2e_specs }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5463c715..fce50342 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -141,15 +141,20 @@ jobs: - name: Build for e2e # Same dir the Playwright webServer serves (playwright.config.ts # falls back to '.next-e2e'; gate-ci.mjs builds there too). + # NEXT_PUBLIC_SITE_URL is inlined at build time, so the canonical + # `.test` origin must reach THIS build — it is what lets the agenda + # feed dialog generate a link under the production build (OPS83). run: pnpm build env: NEXT_DIST_DIR: .next-e2e + NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test - name: E2E tests (full suite, single job, 4 workers) # Explicit CI workers; the local default stays 2 (playwright.config.ts). # No `--` separator: pnpm would forward it verbatim to playwright and # the flag would be parsed as a file pattern ("No tests found"). env: PLAYWRIGHT_WORKERS: 4 + NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test run: pnpm test:e2e deploy: diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index d0cfd781..2ec7a35c 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**OPS83 — desbloquear deploy: estabilizar os e2e do verify sem reduzir cobertura; AgendaFeed volta a rodar em prod (2026-08-24):** o deploy manual (`deploy.yml`) estava bloqueado pelo job `verify` (run #15: 1 failed + 7 flaky + 2 skipped + 1 did-not-run). Causas estudadas e corrigidas por reescrita de specs (settle/poll/hermeticidade/escopo de locator) — **nenhum** `test.flaky`, nenhuma remoção de cobertura, nenhuma mudança de workers/retries, zero mudança de app/contrato/acesso: (1) kill switch da home — o poll de server-HTML convergia enquanto a seção renderizada ainda mostrava o link do Instagram; passou a pollar o **DOM rendered** escopado a `[data-home-section="contents"]` (`expectSectionCardCount`, usado também no sibling de fallback IG); (2) section-hidden — o revalidate `tag=posts` era engolido por `.catch`; agora o POST é **assertado** e a convergência ganha orçamento explícito (não bump cego); (3) SavedFilters strict-mode — o link 'Municípios' agora é escopado ao container `[data-slot="sidebar"]` (2º match = bottom nav, #669 absorvida); (4) People — gate `S:` + settle do stream antes de asserir rows (testes 15, pós-search e C118); (5) Municípios:642 — poll DOM da liderança específica em vez de `.first()` no stream; (6) Municípios:386 B176 — `settleMunicipalityStream` entre remove-chip e o próximo clique de opção; (7) Newsletter:222 — `toHaveURL(/#novidades/)` sem `$` (o `?e2e=scroll-…` é legítimo); (8) DemandVisibility:62 — `.goto` com cache-buster para a asserção de 404 do peer (a cache RSC servia a demanda acessível após a troca de cookie). **AgendaFeed (Decision B):** os 2 `test.skip(isProdMode)` foram **removidos** — o build e2e agora inlina `NEXT_PUBLIC_SITE_URL=https://feed.e2e.teqo.test` (`.test` TLD, passa o fail-closed `requireProductionDNSOrigin`), o diálogo gera o link canônico e os testes asserem o **path** e buscam via `campaign.baseURL + path`; confirmado que share usa `window.location.origin`, webauthn é desacoplado via request-host e webhooks usam `campaign.baseURL` — nenhum outro e2e depende do origin. Escopo config: `playwright.config.ts` (default do webServer env em prod), `deploy.yml` e `ci-pr.yml` (env do build-for-e2e + do passo de testes). Verificação: specs afetadas verdes em dev mode (2×) e AgendaFeed verde em `E2E_PROD=1`; `pnpm lint`/`tsc`/`format:check`/`knip`/`check:cycles`/`test:unit` (2443) verdes; PR em main com auto-merge. + **OPS79 — última migração de dados em produção: plataforma antiga → nova (vertical campanha) (2026-08-23):** com a fonte congelada pelo OPS80 (zero escritas no Neon desde 2026-08-23 22:30 UTC), reconciliação e migração do residual. **Descoberta-chave:** a vertical campanha JÁ estava 100% íntegra no `teo_1313` (aceite do OPS51 confirmado por reconciliação repetível — diff 0 em contagens e junta de IDs das 13 coleções; `municipality_rels` só 3 links de edição de portfólio pós-OPS51, absorvidos como não-dado). O único residual real entre Neon e target era a `signature` pública 1486 (petition `fim-escala-6x1`) com seu contato + subscription, cujo contato originário (Juares) **não existia** no target (o id 2221 do target é outra pessoa). **Ferramentas:** `scripts/reconcile-campaign-vertical.mjs` (read-only, 19/19 PASS, nunca imprime PII) + `scripts/migrate-signature-orphan.mjs` (dry-run/apply, guard `OPS79_MIGRATE_CONFIRM=1`, transação única, PII mascarada nos logs). **Execução 100% no homeserver:** aplicado contact novo **2225** + signature **1486** preservada + subscription **1492** + sequences ajustadas; pós-verificação `signature` 1485=1485, reconcile 19/19, smoke `/` `/campanha` `/admin` 200. Runbook (rollback = DELETE das 3 linhas + setval) em `docs/ops/teqo-1313-deploy.md` §OPS79. Sem migration de schema, sem access/Consent novo, sem UI. Closes #797. **E2E-FULL-FLAKE-C142MUN — endurecer write-chrome assertions sob 4 workers (2026-08-23):** as 3 asserts de `campaignPermissionProfile.e2e.spec.ts:85` (carteira FAB visível), `:101` (tudo FAB visível) e `campaignMunicipalities.e2e.spec.ts:115` (checkbox de advisor no `/editar`) falhavam consistentemente no `deploy.yml → verify` (suíte full, 4 workers, prod build) por latência de render/hydration do shell streamed sob carga — a sonda SSR prova que o FAB está correto no HTML; o falha é que `networkidle` finda antes do chunk `S:` commitar, e a assert `toBeVisible`/`.check()` usa o budget default que é insuficiente no runner hosted. Fix: adicionar `page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)` (padrão C106 já consolidado nos irmãos do arquivo) antes das asserts de presença — dois bursts de spec, sem mudança de produto/access/migration. Verificado: `gate:fast` (2439 unit tests, all green); e2e das 2 specs isoladas + repeat-each=3 em 4 workers: 21/21 passed. Sem alteração de access, sem migration, sem redução de workers do full. diff --git a/docs/changelog/2026-08-24-ops83.md b/docs/changelog/2026-08-24-ops83.md new file mode 100644 index 00000000..c239cfa1 --- /dev/null +++ b/docs/changelog/2026-08-24-ops83.md @@ -0,0 +1 @@ +**OPS83 — desbloquear deploy: estabilizar os e2e do verify sem reduzir cobertura; AgendaFeed volta a rodar em prod (2026-08-24):** o deploy manual (`deploy.yml`) estava bloqueado pelo job `verify` (run #15: 1 failed + 7 flaky + 2 skipped + 1 did-not-run). Causas estudadas e corrigidas por reescrita de specs (settle/poll/hermeticidade/escopo de locator) — **nenhum** `test.flaky`, nenhuma remoção de cobertura, nenhuma mudança de workers/retries, zero mudança de app/contrato/acesso: (1) kill switch da home — o poll de server-HTML convergia enquanto a seção renderizada ainda mostrava o link do Instagram; passou a pollar o **DOM rendered** escopado a `[data-home-section="contents"]` (`expectSectionCardCount`, usado também no sibling de fallback IG); (2) section-hidden — o revalidate `tag=posts` era engolido por `.catch`; agora o POST é **assertado** e a convergência ganha orçamento explícito (não bump cego); (3) SavedFilters strict-mode — o link 'Municípios' agora é escopado ao container `[data-slot="sidebar"]` (2º match = bottom nav, #669 absorvida); (4) People — gate `S:` + settle do stream antes de asserir rows (testes 15, pós-search e C118); (5) Municípios:642 — poll DOM da liderança específica em vez de `.first()` no stream; (6) Municípios:386 B176 — `settleMunicipalityStream` entre remove-chip e o próximo clique de opção; (7) Newsletter:222 — `toHaveURL(/#novidades/)` sem `$` (o `?e2e=scroll-…` é legítimo); (8) DemandVisibility:62 — `.goto` com cache-buster para a asserção de 404 do peer (a cache RSC servia a demanda acessível após a troca de cookie). **AgendaFeed (Decision B):** os 2 `test.skip(isProdMode)` foram **removidos** — o build e2e agora inlina `NEXT_PUBLIC_SITE_URL=https://feed.e2e.teqo.test` (`.test` TLD, passa o fail-closed `requireProductionDNSOrigin`), o diálogo gera o link canônico e os testes asserem o **path** e buscam via `campaign.baseURL + path`; confirmado que share usa `window.location.origin`, webauthn é desacoplado via request-host e webhooks usam `campaign.baseURL` — nenhum outro e2e depende do origin. Escopo config: `playwright.config.ts` (default do webServer env em prod), `deploy.yml` e `ci-pr.yml` (env do build-for-e2e + do passo de testes). Verificação: specs afetadas verdes em dev mode (2×) e AgendaFeed verde em `E2E_PROD=1`; `pnpm lint`/`tsc`/`format:check`/`knip`/`check:cycles`/`test:unit` (2443) verdes; PR em main com auto-merge. diff --git a/docs/plans/desbloquear-deploy-e2e-verify-impl.md b/docs/plans/desbloquear-deploy-e2e-verify-impl.md new file mode 100644 index 00000000..331f8e8d --- /dev/null +++ b/docs/plans/desbloquear-deploy-e2e-verify-impl.md @@ -0,0 +1,130 @@ +# Impl: OPS83 — Desbloquear deploy: estabilizar os e2e do verify (falhas + flakes) sem reduzir cobertura; entregar deploy real + +Status: rascunho +Atualizado em: 2026-08-24 +Issue: #824 +Intenção: docs/plans/desbloquear-deploy-e2e-verify.md +Appetite restante: herdado (~2–3 dias eng; outcome verificável = deploy manual real verde na intenção; a forma técnica aqui fica dentro de "reescrever specs", que já foi human-gated) + +## Leitura da intenção + +- **Outcome:** um deploy manual a partir de main termina com o job `verify` verde (suíte e2e **full**, prod mode, 4 workers) e o job `deploy` concluído — app novo no ar. Baseline #15 (HEAD a2db543e): 1 failed + 7 flaky + 2 skipped + 1 did-not-run + 191 passed. +- **O que NÃO negociar:** não reduzir cobertura; não `test.flaky()`; não subir workers/retries como estratégia; os 2 skips do `campaignAgendaFeed` são **removidos** (rodam em prod mode); tratar a **família inteira** de classes estudadas (cast rotativa), não só as que falharam no #15; nunca tocar contrato público nem modelo de acesso. +- **O que reavaliar:** (1) a hipótese de "DOM-scoped poll vs server-HTML poll" na home pode revelar que o helper atual `waitForHomeHTML` precisa ser _parametrizado_, não duplicado — depth check antes de criar twin; (2) se a causa raiz de `frontend:781` for só propagação de ISR, o conserto pode ser um _clearing determinístico da cache_ em vez de bump de orçamento cego. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[Fase 1: asserts de shape\nNewsletter + SavedFilters] --> B[Fase B: Decision B\nAgendaFeed prod mode] + B --> C[Fase 2: stream settle\nPeople + Municipios:642] + C --> D[Fase 3: optimistic/POST\nMunicipios:386] + D --> E[Fase 4: home DOM poll\nfrontend:781 + :1413] + E --> F[Fase 5: swap hermético\nDemandVisibility:62] + F --> G[Fase 6: família + CI verify] +``` + +**Opções consideradas:** A (só consertar as 9 que falharam no #15) | B (**família inteira de classes**, reescrita settle/poll/hermeticidade/escopo, como manda a intenção human-gated). + +**Recomendação:** B — porque a cast rotativa faz a suíte re-falhar em classes irmãs a cada run; parar em A resolve o #16 e entrega o deploy amanhã, mas não cumpre o aceite de "2 runs consecutivos de verify com zero failed/flaky recorrente nas classes estudadas". Família aqui = os specs que **compartilham a classe de race**, não "rodar tudo de novo": as reescritas de helper da home e o padrão settle/`expectPostResponse` são aplicados aos testes-irmãos do mesmo serial-describe/spec que compartilham o mesmo código. + +**Rejeitadas:** A porque resolve só os sintomas do #15 (a cast rotativa, estatística de 4 workers, rearranja as falhas no #16); e qualquer caminho de "configuração mágica" (workers/retries/`test.flaky`) porque viola o mandato de produto. + +### Decisões de engenharia + +- **D-1 — Home convergence (`frontend:1413` kill switch, `frontend:781` section hidden).** + - Opções: A) continuar polling **server-HTML** body-wide e só trocar `toHaveCount` por `expect.poll`; B) **parametrizar `waitForHomeSectionState`/`waitForHomeHTML` para pollar o DOM rendered**, escopado ao `[data-home-section="contents"]`, e reter o server poll como gate de convergência secundário; C) apagar a cache ISR (`posts` fetch-cache) deterministamente antes de cada teste. + - **Recomendação: B (+C para a ISR de `frontend:781`)** — porque o bug de `1413` é o IG card renderizar _no cliente dentro do payload de seção_ mesmo com a ausência convergida no body-server; pollar o DOM rendered é a ÚNICA fonte que observa o payload de seção. Reuso: `gotoHomeFresh` (cache-busting de navegação já existe), `expect.poll` (já usado no spec). Depth check: **estender** `waitForHomeSectionState` para aceitar `page` + `scope`, não criar `waitForHomeSectionDom` twin. Para `781`, a causa é propagação de ISR sob load; o trato é: (a) afirmar que o POST de `revalidate?tag=posts` **aterrissou** via `expectPostResponse(page, '/api/revalidate')` (determinístico, não wall-clock), e (b) esperar a convergência no DOM (não só no body) com orçamento explícito. Não subir "12s→N cego". + - **Rejeitadas:** A porque não observa o payload de seção (o próprio flake); C-sozinho porque não cobre o componente cliente IG e surge como twin do helper existente (violação de depth check / reaproveitamento). +- **D-2 — AgendaFeed prod mode (2 skips).** + - Opções: A) mudança de **app** (override explícito de origem na action); B) mudança só de **teste/config**: no `webServer.env` em prod mode setar `NEXT_PUBLIC_SITE_URL=https://feed.e2e.teqo.test` (origem canônica HTTPS, satisfaz `requireProductionDNSOrigin` — `DNS_HOSTNAME_PATTERN` casa, não é `localhost`/IP/`.local*`/`.internal`) e nos 2 testes manter o fluxo do diálogo, asserir o `path` (`/campanha/agenda/ical/<36>`), e buscar o feed via `campaign.baseURL + path` (hermético contra o server local). + - **Recomendação: B** — porque `requireProductionDNSOrigin` (validado em `src/utilities/campaignInviteOrigin.ts`) aceita `.test`-TLD; produção real continua fail-closed (o `NEXT_PUBLIC_SITE_URL` de prod é DNS público real); nenhum dead-open é introduzido — a configuração canônica é injetada só no server de teste do Playwright. Confirmado: **nenhuma mudança de app é estritamente exigida**; a action `createCalendarFeedLink` → `buildFeedUrl` lê `NEXT_PUBLIC_SITE_URL` do processo do server e passa a usar a origem canônica de teste, gerando o mesmo caminho; o teste ignora o origin absoluto ao buscar. + - **Rejeitadas:** A porque muda código de produção (contrato/segurança) para ceder terreno a teste — desnecessário e mais caro de reverter. +- **D-3 — Strict-mode locator `campaignSavedFilters:64` (`Municípios` resolve 2).** + - Opções: A) `.first()`/`.last()`; B) escopar ao bar-região sidebar onde o shortcut vive (ex.: `campaignPageChrome` → container de sidebar) antes do `getByRole('link', {name:'Municípios', exact:true})`; C) trocar por `goto` (abandonaria a navegação hermética). + - **Recomendação: B, fallback A** — porque a intenção explicitamente preserva o _goto-avoidance_ (navegação por link evita o `net::ERR_ABORTED` de RSC in-flight); escopar ao container de sidebar é o mais correto semânticamente (é o "Municípios" da navegação que queremos). O strict-mode (#669) é a feature que expôs o 2º match — absorvida aqui como causa-raiz. + - **Rejeitadas:** C porque reintroduz a race que o comentário do teste documenta (medida em CI 2026-07-30). +- **D-4 — DSPR/streaming settle `campaignPeople:37` + `campaignMunicipalities:709`.** + - Opções: A) aplicar em cada teste o `waitForFunction(div[id^="S:"]===0)` (padrão `campaignMunicipalities:708`) **seguido de um poll DOM escopado na row específica**; B) só o `S:` gate genérico; C) `expect(locator).toBeVisible()` hard (estado atual). + - **Recomendação: A** — `S:` gate existente já provado no spec irmão; mas o comentário em `:642` admite que o chunk da lista pode cair depois do quiesce, então o gate genérico NÃO é suficiente — o poll em `expect.poll` escopado na row é o que observa o payload real. Recomendado **evitar `.first()`** em stream não-determinístico de milhares (usa-se poll até stabilizar match único). + - **Rejeitadas:** B porque o comentário do próprio spec admite que o chunk atrasa além do `S:`; C é a causa da flake. +- **D-5 — Optimistic auto-apply `campaignMunicipalities:386` (facet churn).** Envolver cada interação removendo chip em `expectPostResponse(page, ...)` (fixture já provê, `:530`), e trocar `toHaveCount`/clique hard por `expect.poll` em contagem de chips e presença de opção. Opções de isolar o churn: esperar `listbox` re-render → `expect.poll` no count (Recomendação); `waitForTimeout` cego (Rejeitada — enche, não estabiliza). +- **D-6 — Swap hermenêutico `campaignDemandVisibility:62`.** Após `sessionFor(peer)` + `goto`, a navegação pode vir de RSC cache/stream da troca responsável→peer (servindo a demanda acessível em vez do 404). Opções: A) `.goto(URL, { cache: 'no-store' })`/`cache-buster` no `demandURL` da asserção de 404; B) `router.refresh()`/"nova requisição" entre os swaps; C) navegar para `about:blank` e voltar ao `demandURL` com cache-buster. **Recomendação: A** (fetch bypass de cache na navegação da asserção) — mínimo e mirar a raiz (RSC serve da cache). A guarda do swap em si (`sessionFor`→`clearCookies`+token) permanece. +- **D-7 — "1 did not run" do #15.** **Não virar task**: explicar por run (worker crash ou `serial-describe` abortado quando um teste do describe falha). Tratar como observável no Fase 6 se reproduzir, nunca como item barato de primeiro. + +## Componentes / mudanças + +- **`tests/e2e/frontend.e2e.spec.ts`** (serial `Campaign home content section`): parametrizar portar os helpers `waitForHomeSectionState`/`waitForHomeHTML` para aceitar `page` + scope de seção; reescrever `781` e `1413` para DOM-poll escopado; aplicar o mesmo padrão aos irmãos `1276/1337` (S2 share + IG fallback) que compartilham a classe IG card/API-down. +- **`playwright.config.ts`**: no bloco `webServer` do prod mode (`isProdMode`), setar `NEXT_PUBLIC_SITE_URL` para `https://feed.e2e.teqo.test` quando não vier do ambiente (canonical test origin) — o default atual é `baseURL` (localhost), que `requireProductionDNSOrigin` rejeita. Não tocar o `dev` mode. +- **`tests/e2e/campaignAgendaFeed.e2e.spec.ts`**: remover os 2 `test.skip(isProdMode, …)`; manter o fluxo do diálogo (cover); asserir path `/campanha/agenda/ical/<36>`; buscar via `campaign.baseURL + path` ao invés do URL absoluto. +- **`tests/e2e/campaignSavedFilters.e2e.spec.ts`**: escopar o `getByRole('link', {name:'Municípios', exact:true})` ao container de sidebar (fallback `.first()`). +- **`tests/e2e/campaignPeople.e2e.spec.ts`** (e irmãos `239/338`): `waitForFunction(S:)` + poll DOM na row específica; evitar `.first()` no stream. +- **`tests/e2e/campaignMunicipalities.e2e.spec.ts`**: `386` → `expectPostResponse` + `expect.poll` em chips/opções; `642` → poll DOM na row específica. +- **`tests/e2e/campaignNewsletter.e2e.spec.ts`**: `222` → em `toHaveURL(/#novidades/)` (drop do `$`) ou assert de `location.hash`. +- **`tests/e2e/campaignDemandVisibility.e2e.spec.ts`**: `62` → cache-busted `.goto` na asserção de 404 do peer. +- **Migration:** sem migration (nenhuma mudança de schema). +- **Access / Consent:** nenhuma; nada toca modelo de acesso nem Consent — parede de produto respeitada. +- **UI:** N/A — sem UI. + +## Dados → forma (se aplicável) + +N/A — sem superfície de dados; o "dado" do aceite é o resultado do workflow (failed/flaky/skipped por run). + +## Fases verificáveis + +1. **Fase 1 — asserts de shape (barato, zero risco).** + - `campaignNewsletter:222` (regex sem `$`); `campaignSavedFilters:26` (escopo sidebar). + - Verificação: `pnpm test:e2e --no-deps --project=campaign -- tests/e2e/campaignNewsletter.e2e.spec.ts` e `-- tests/e2e/campaignSavedFilters.e2e.spec.ts` (rodar cada 2× local). +2. **Fase B — Decision B, AgendaFeed prod mode (track independente).** + - `playwright.config.ts` `webServer.env` prod; reescrever os 2 testes sem `skip`. + - Verificação: `pnpm test:e2e --no-deps --project=campaign -- tests/e2e/campaignAgendaFeed.e2e.spec.ts` com `E2E_PROD=1`; confirmar que os 2 voltaram a **passar** em prod mode (antes: skipped) e que o C113 continua verde. +3. **Fase 2 — stream settle.** + - `campaignPeople:15` (+ irmãos 239/338); `campaignMunicipalities:642`. + - Verificação: `pnpm test:e2e --no-deps --project=campaign -- tests/e2e/campaignPeople.e2e.spec.ts` e `-- tests/e2e/campaignMunicipalities.e2e.spec.ts` (2×). +4. **Fase 3 — optimistic/POST.** + - `campaignMunicipalities:386` (`expectPostResponse` + `expect.poll`). + - Verificação: mesma spec 2× + `--grep "B176"` no no-deps. +5. **Fase 4 — home DOM poll.** + - `frontend.e2e.spec.ts`: parametrizar helpers; reescrever `781` + `1413`; alinhar irmãos `1276/1337`. + - Verificação: `pnpm test:e2e --no-deps --project=frontend -- tests/e2e/frontend.e2e.spec.ts` (2×; serial describe dá determinismo). +6. **Fase 5 — swap hermétic.** + - `campaignDemandVisibility:62`. + - Verificação: `pnpm test:e2e --no-deps --project=campaign -- tests/e2e/campaignDemandVisibility.e2e.spec.ts` (2×). +7. **Fase 6 — família inteira + job verify.** + - Rodar a **família de classes estudadas** (não só o #15): re-rodar todos os specs tocados **mais** os irmãos da classe (frontend S2/IG, municipios stream/omnibox, people stream) e confirmar zero failed/flaky recorrente. + - Verificação local full family; depois `CI`/verificar o job `verify` do `.github/workflows/deploy.yml` (via `workflow_dispatch`) — o mete o `deploy`. + +## Rabbit holes / Não escopo (engenharia) + +- Correr atrás de 1 flake isolado e fechar (rabbit hole 1 da intenção). +- "Marcar flaky/retry 3+"; subir workers (rabbit hole 2). +- Infra nova de "um server/DB por worker" sem tentar o barato primeiro (rabbit hole 3). +- O "1 did not run" do #15: **não presumir** — se não reproduzir no Fase 6, é explicado por run (worker crash / serial abort) e documentado, não vira task. +- `campaignLeaderships:59`, `campaignActivity:131`, `campaignPermissionProfile:85/101` (já curado em a97cd56f): **verificar** (Fase 6) mas não reescrever à toa se verde. + +## Riscos e mitigação + +- **Test hidden em prod mode volta a falhar por origem canônica não-resolvível.** Mitigação: a busca do feed usa `campaign.baseURL + path` (ignora o origin absoluto), e o origin canônico é só a string do `NEXT_PUBLIC_SITE_URL` do server de teste — nunca navegamos para ele. `requireProductionDNSOrigin` foi validado contra `.test`. +- **Helper DOM poll regressa os irmãos do serial-describe da home.** Mitigação: re‑rodar o `frontend` serial inteiro 2× (Fase 4); reuso em vez de twin reduz a superfície. +- **Dom-poll nova fonte de timeout** (orçamento). Mitigação: `expect.poll` com timeout explícito herdado do `expect` (`10s` default), não hard assert. +- **Fase 1 quiebra strict-mode em outro ponto** (unit C142 #805). Mitigação: rodar unit/int da suíte afetada (`pnpm gate:fast` de domínio) ao fechar cada fase. +- **Deploy verde num run, vermelho no próximo (cast rotativa).** Mitigação: aceite exige **2 runs consecutivos**; família inteira no Fase 6, não só o #15. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto: deploy manual `verify` verde + `deploy` concluído (sem app change além do já-curtado a97cd56f) +- [ ] Invariantes AGENTS/engineering-standards: nada de contrato público / acesso / Consent; reuso de helpers (`expectPostResponse`, `waitForFunction(S:)`, `waitForRouterSettled`, `gotoHomeFresh`) em vez de twins +- [ ] Nenhum `test.flaky()`, nenhum skip removido indevidamente (os 2 skips do AgendaFeed **viram execução** em prod mode) +- [ ] Família de classes estudadas re-rodada (não só o #15); zero failed/flaky recorrente em 2 runs +- [ ] Unit/int de domínios tocados verdes (`pnpm gate:fast`) a cada fase com mudança de helper + +## Decision quality self-score + +1. Decisões caras têm rejeitadas? **Sim** — D-1 (A/C rejeitadas), D-2 (A rejeitada), D-3 (C rejeitada), D-4 (B/C), D-5 (timeout cego), D-6 (B/C). +2. Abordagem cabe no appetite da intenção? **Sim** — script de família-estudada + reescritas locais; sem infra nova (~2–3 dias). +3. Rabbit holes nomeados? **Sim** — seção própria. +4. Depth check: reusa shells/helpers existentes? **Sim** — parametriza `waitForHomeSectionState` (não twin), reusa `expectPostResponse`/`waitForFunction(S:)`/`waitForRouterSettled`/`gotoHomeFresh`/`campaign.baseURL`. +5. Intenção (aceite de produto) permanece satisfeita? **Sim** — sem perda de cobertura; sem toc em contrato/acesso; os 2 skips viram execução. + **Score: 5/5 (gate ≥4).** + +Todos os diagnósticos foram grounded no código real (specs, `waitForHomeHTML`, `requireProductionDNSOrigin` validando `.test`, `expectPostResponse`, padrão `S:`), e a Decisão B (AgendaFeed prod mode) foi confirmada como test/config-only — sem mudança de app exigida. diff --git a/playwright.config.ts b/playwright.config.ts index 41446ad1..2d2696eb 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -68,6 +68,16 @@ const youtubeStubUrl = youtubeStubUrlFor(baseURL) const youtubeStubPort = new URL(youtubeStubUrl).port const instagramStubUrl = instagramStubUrlFor(baseURL) const instagramStubPort = new URL(instagramStubUrl).port +/* + * Canonical HTTPS origin the e2e prod build inlines as NEXT_PUBLIC_SITE_URL + * (OPS83 Decision B): `requireProductionDNSOrigin` demands an HTTPS public DNS + * name, so the agenda feed dialog can generate a link under the production + * build. `.test` is a reserved TLD (RFC 6761) — never resolves, exists only so + * the feed link passes the guard; the specs fetch the feed via `campaign.baseURL + * + path`, never this origin. + */ +const CANONICAL_E2E_ORIGIN = 'https://feed.e2e.teqo.test' + const workersOverride = process.env.PLAYWRIGHT_WORKERS const workers = workersOverride === undefined ? 2 : Number(workersOverride) if (!Number.isInteger(workers) || workers < 2) { @@ -216,7 +226,14 @@ export default defineConfig({ // owns that entire directory and may clear nested production bundles. NEXT_DIST_DIR: process.env.NEXT_DIST_DIR ?? '.next-e2e', PAYLOAD_SECRET: process.env.PAYLOAD_SECRET ?? 'test-only-secret-not-used-in-production', - NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL ?? baseURL, + // Prod mode serves a production build where `getCampaignInviteBaseURL` + // fails closed unless NEXT_PUBLIC_SITE_URL is an HTTPS public DNS name + // (C98). The e2e build inlines this same value (see the "Build for e2e" + // steps in deploy.yml/ci-pr.yml), so the dialog-generated feed link is + // canonical — and the spec fetches it back via `campaign.baseURL`, never + // the absolute origin (OPS83 Decision B). + NEXT_PUBLIC_SITE_URL: + process.env.NEXT_PUBLIC_SITE_URL ?? (isProdMode ? CANONICAL_E2E_ORIGIN : baseURL), // Lets e2e specs bust the `posts` tag after direct REST deletes (cleanup), // mirroring the documented post-seed runbook against the deployed site. REVALIDATE_SECRET: process.env.REVALIDATE_SECRET ?? 'e2e-revalidate-secret', diff --git a/tests/e2e/campaignAgendaFeed.e2e.spec.ts b/tests/e2e/campaignAgendaFeed.e2e.spec.ts index 6c6a8573..0196f5a6 100644 --- a/tests/e2e/campaignAgendaFeed.e2e.spec.ts +++ b/tests/e2e/campaignAgendaFeed.e2e.spec.ts @@ -5,22 +5,17 @@ import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js' import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js' /** - * The full generate → fetch flow needs the app served from a local origin: - * `getCampaignInviteBaseURL` rejects non-public DNS in NODE_ENV=production - * (fail-closed — the feed URL must be canonical), and CI/`pnpm push` boot the - * production build (`E2E_PROD=1`) with `NEXT_PUBLIC_SITE_URL=http://localhost:3000`. - * The CI covers the unlocked dialog via the C93 component unit and the - * filterless feed content via the C93 int tests; the e2e here pins the real - * end-to-end flow on the dev server. + * OPS83 Decision B — these flows now run under the production build too: the + * e2e build inlines `NEXT_PUBLIC_SITE_URL=https://feed.e2e.teqo.test` + * (`.test` TLD, passes `requireProductionDNSOrigin` fail-closed), so the + * dialog can generate a canonical feed link. The tests assert the PATH and + * fetch the feed via `campaign.baseURL + path` — never the canonical origin, + * which is deliberately non-resolving. */ -const isProdMode = - ['1', 'true'].includes(process.env.E2E_PROD ?? '') || ['1', 'true'].includes(process.env.CI ?? '') - test.describe('Agenda — link de import (C98)', () => { test.setTimeout(90_000) test('gera o link sem filtros pelo header e o feed responde iCal', async ({ campaign, page }) => { - test.skip(isProdMode, 'fluxo completo exige origem local (dev server)') const { fixtures } = campaign const coordinator = await fixtures.createCampaignUser('coordinator') const municipality = await fixtures.claimMunicipality() @@ -61,10 +56,13 @@ test.describe('Agenda — link de import (C98)', () => { const linkInput = dialog.getByRole('textbox', { name: 'Link de import' }) await expect(linkInput).toBeVisible({ timeout: 15_000 }) const feedUrl = await linkInput.inputValue() + // The link carries the canonical origin (OPS83 Decision B) — the PATH is + // the contract, the origin is test-only and deliberately non-resolving. expect(feedUrl).toMatch(/\/campanha\/agenda\/ical\/[0-9a-f-]{36}$/) // O link responde com o feed iCal do recorte pedido (a atividade criada). - const response = await page.request.get(feedUrl) + // Fetch against the local server by path — never the absolute origin. + const response = await page.request.get(`${campaign.baseURL}${new URL(feedUrl).pathname}`) expect(response.status()).toBe(200) expect(response.headers()['content-type']).toContain('text/calendar') const body = await response.text() @@ -73,7 +71,6 @@ test.describe('Agenda — link de import (C98)', () => { }) test('gera o link sem filtros pelo FAB mobile', async ({ campaign, page }) => { - test.skip(isProdMode, 'fluxo completo exige origem local (dev server)') const { fixtures } = campaign const coordinator = await fixtures.createCampaignUser('coordinator') const municipality = await fixtures.claimMunicipality() diff --git a/tests/e2e/campaignDemandVisibility.e2e.spec.ts b/tests/e2e/campaignDemandVisibility.e2e.spec.ts index 8670238c..f393d37c 100644 --- a/tests/e2e/campaignDemandVisibility.e2e.spec.ts +++ b/tests/e2e/campaignDemandVisibility.e2e.spec.ts @@ -95,8 +95,13 @@ test.describe('C143 — demand visibility (explicit responsibles)', () => { await page.getByRole('button', { name: 'Salvar responsáveis' }).click() await expect(page.getByRole('button', { name: `Remover ${peer.name}` })).toHaveCount(0) + // The peer already rendered THIS URL (granted, line 88); the Next router + // cache serves the previous RSC payload on a plain goto even after the + // cookie swap (OPS83 run #15: the accessible demand showed instead of the + // 404). The route ignores unknown query params — bust the cache so the + // revoked access is observed from the server. await campaign.sessionFor(context, peer) - await page.goto(demandURL) + await page.goto(`${demandURL}?e2e=${Date.now()}`) await expect(page.getByText('This page could not be found.')).toBeVisible() }) }) diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index 5b8b6750..decb9668 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -45,6 +45,16 @@ const visibleMunicipalityButton = (page: Page, name: string) => .getByRole('button', { name: new RegExp(`^${escapeRegExp(name)}(?: —|$)`) }) .filter({ visible: true }) +/** + * Waits for the municipality list's RSC stream to commit (no transient `S:` + * shell copies) before interacting with the rows/options again. Chip removal + * and omnibox facet changes are client-side `navigate` calls that re-stream + * the list; clicking an option against the stale DOM races that stream + * (observed in the OPS83 verify run #15 at 4 workers). + */ +const settleMunicipalityStream = (page: Page) => + page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) + const ensureWideMunicipalityList = async (page: Page) => { // The resizable panel hydrates independently from the RSC list and can // change the container stage after a cell first becomes visible. These @@ -437,9 +447,12 @@ test.describe('Municípios — jornadas por papel', () => { await expect(tableRows).toHaveCount(1) await expect(page.getByRole('link', { name: municipalityA.name, exact: true })).toBeVisible() - // Removing the chip restores the unfiltered 25-row page 1. + // Removing the chip restores the unfiltered 25-row page 1. The removal is + // a client `navigate` — poll the chip out and let the stream commit before + // touching the omnibox again (OPS83: the next option click raced this). await removeChip(`Dobradinha: ${deputyName} (BR176)`).click() await expect(removeChip(`Dobradinha: ${deputyName} (BR176)`)).toHaveCount(0) + await settleMunicipalityStream(page) // Partido by acronym → the same single municipality (its only dobradinha). await omnibox.fill('BR176') @@ -450,6 +463,7 @@ test.describe('Municípios — jornadas por papel', () => { await removeChip('Partido: BR176').click() await expect(removeChip('Partido: BR176')).toHaveCount(0) + await settleMunicipalityStream(page) // Liderança by contact name → municipality B only. await omnibox.fill(leadership.contactName) @@ -704,9 +718,13 @@ test.describe('Municípios — jornadas por papel', () => { // C106 — the dossiê tab streams a transient `S:` shell copy; under load the // leadership-list chunk can land after `goto` settles, so let the stream // commit before asserting the leadership link (same class as the `:115` - // editar checkbox and the C142 FAB). + // editar checkbox and the C142 FAB). The generic `S:` gate alone is not + // enough (observed in the OPS83 verify run #15): poll the DOM for the + // specific link instead of `.first()` on the nondeterministic stream. await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) - await expect(page.getByRole('link', { name: contact.name }).first()).toBeVisible() + const leadershipLink = page.getByRole('link', { name: contact.name }) + await expect.poll(() => leadershipLink.count(), { timeout: 15_000 }).toBeGreaterThan(0) + await expect(leadershipLink.first()).toBeVisible() await page.getByLabel('Quantos votos a liderança traz neste município?').fill('250') await page.getByRole('button', { name: 'Declarar' }).click() await expect(page.getByText('Declaração de votos registrada.')).toBeVisible() diff --git a/tests/e2e/campaignNewsletter.e2e.spec.ts b/tests/e2e/campaignNewsletter.e2e.spec.ts index cb6a1e3a..83abac14 100644 --- a/tests/e2e/campaignNewsletter.e2e.spec.ts +++ b/tests/e2e/campaignNewsletter.e2e.spec.ts @@ -222,7 +222,9 @@ test.describe('Campaign home novidades hash scroll', () => { test('hero CTA scrolls to the newsletter section (mobile)', async ({ page }) => { await page.goto(`/?e2e=scroll-${scrollSuffix}`) await page.locator('[data-cta="secondary"]').click() - await expect(page).toHaveURL(/#novidades$/) + // The URL legitimately carries the `?e2e=scroll-…` cache-buster query, + // so assert only the hash (no trailing `$`), which lands after the click. + await expect(page).toHaveURL(/#novidades/) await expect(page.locator('[data-home-section="newsletter"]')).toBeInViewport() }) diff --git a/tests/e2e/campaignPeople.e2e.spec.ts b/tests/e2e/campaignPeople.e2e.spec.ts index 0e4f4c30..cb452204 100644 --- a/tests/e2e/campaignPeople.e2e.spec.ts +++ b/tests/e2e/campaignPeople.e2e.spec.ts @@ -34,6 +34,11 @@ test.describe('Pessoas — lista unificada', () => { // The merged row carries the leadership person with its capacity chips // (desktop table — the mobile card twin is `md:hidden` at this viewport). + // The people list streams RSC chunks; under load the row can land after + // `goto`'s settle, so let the stream commit and then poll the DOM for the + // specific row (same class as the `S:` gate at campaignMunicipalities:708, + // observed as a flake in the OPS83 verify run #15 at 4 workers). + await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) await expect(page.getByRole('row', { name: new RegExp(contactName) })).toBeVisible() // The coordinator account itself is a staff person (with a ficha) — ≥ 2 rows. await expect(page.getByText(/^\d+ pessoas?$/)).toBeVisible() @@ -43,6 +48,8 @@ test.describe('Pessoas — lista unificada', () => { await omnibox.fill(contactName.slice(0, 8)) await omnibox.press('Enter') await expect(page).toHaveURL(/q=/) + // The filter navigation re-streams the list — let it commit before the row. + await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) await expect(page.getByRole('row', { name: new RegExp(contactName) })).toBeVisible() }) @@ -95,6 +102,9 @@ test.describe('Pessoas — lista unificada', () => { await campaign.login(page, coordinator.email!, coordinator.password) await page.goto('/campanha/pessoas') + // The list streams RSC chunks; clicking the name link against the stale DOM + // races the stream (OPS83: C118 flaked under parallel load). + await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) await page.getByRole('link', { name: contactName, exact: true }).click() await expect(page).toHaveURL(new RegExp(`/campanha/pessoas/${contactId}$`)) @@ -329,6 +339,8 @@ test.describe('Pessoas — lista unificada', () => { await expect(page.getByText('Ausência: Sem contato')).toBeVisible() await expect(page.getByText('Ausência: Sem base')).toBeVisible() await expect(page.getByText('Ausência: Sem assessor')).toBeVisible() + // Multi-facet navigation re-streams the list — commit before asserting rows. + await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) await expect(page.getByRole('row', { name: new RegExp(noPhoneName) })).toBeVisible() // The union widened: the staff leaderships have no advisors, so they match // `sem_assessor` — the row hidden by `sem_contato` alone is back in. diff --git a/tests/e2e/campaignSavedFilters.e2e.spec.ts b/tests/e2e/campaignSavedFilters.e2e.spec.ts index f38db92f..757fb74a 100644 --- a/tests/e2e/campaignSavedFilters.e2e.spec.ts +++ b/tests/e2e/campaignSavedFilters.e2e.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test' + import { expect, test } from './fixtures/campaignE2EFixtures.js' /** @@ -22,6 +24,13 @@ const RENAMED = 'Recorte renomeado' */ const NAVIGATION = { timeout: 20_000 } +/** + * The nav link, scoped to the desktop sidebar: the same name also exists in the + * mobile bottom nav, so a bare `getByRole` violates strict mode (#669, OPS83). + */ +const municipalityNavLink = (page: Page) => + page.locator('[data-slot="sidebar"]').getByRole('link', { name: 'Municípios', exact: true }) + test.describe('Filtros salvos de Municípios', () => { test('saves the current recorte, restores it from the sidebar and deletes it', async ({ campaign, @@ -61,7 +70,7 @@ test.describe('Filtros salvos de Municípios', () => { // Another recorte is not this one, and the shortcut brings it back. Use the // sidebar link — a bare `goto` here races an in-flight RSC navigation and // flakes as net::ERR_ABORTED under prod load (measured in CI 2026-07-30). - await page.getByRole('link', { name: 'Municípios', exact: true }).click() + await municipalityNavLink(page).click() await expect(page).toHaveURL('/campanha/municipios', NAVIGATION) await expect(shortcut).not.toHaveAttribute('aria-current', 'page') await shortcut.click() @@ -81,7 +90,7 @@ test.describe('Filtros salvos de Municípios', () => { await expect(page.getByRole('link', { name: RENAMED })).toHaveCount(0) // Emptying the group unmounts the sub-list, so focus lands on the nav link // rather than falling to . - await expect(page.getByRole('link', { name: 'Municípios', exact: true })).toBeFocused() + await expect(municipalityNavLink(page)).toBeFocused() // Deleting is undoable, which is why it never asks for confirmation. await page.getByRole('button', { name: 'Desfazer' }).click() diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index b0e7fd27..faf911a0 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -690,6 +690,20 @@ test.describe('Campaign home content section', () => { } } + /** + * Polls the RENDERED contents section for a card/link count, scoped to + * `[data-home-section="contents"]`. The server-HTML polls above converge on + * the ISR response, but the page may serve a heuristic-cached body that lags + * the server (measured in the OPS83 verify run #15: the server showed the + * kill switch applied while the rendered section still held the Instagram + * link). This is the source that observes the actual payload — pair it with + * `gotoHomeFresh` so the navigation itself misses the cache. + */ + const expectSectionCardCount = (locator: Locator, expected: number, timeout = 15_000) => + expect + .poll(async () => locator.count(), { timeout, message: 'contents section card count' }) + .toBe(expected) + // The home route is ISR (s-maxage 300, no max-age): Chromium applies a // heuristic cache to such responses and a plain goto may serve the previous // body even after the poll converged (measured on the S2 snapshot tests — @@ -782,17 +796,20 @@ test.describe('Campaign home content section', () => { // The dev server persists its `posts` cache to .next-e2e/cache/fetch-cache // between runs, so an earlier full-state run could leave it populated. // Busting first makes the empty state deterministic on every rerun. - await request - .post(`${baseURL}/api/revalidate?tag=posts`, { - headers: { 'x-revalidate-secret': revalidateSecret }, - }) - .catch(() => undefined) - await waitForHomeSectionState(request, 'absent') + // OPS83: assert the revalidate actually landed (a swallowed failure made + // the server poll below time out in verify run #15) and converge on the + // rendered DOM with an explicit ISR-regeneration budget — never a blind + // wall-clock bump. + const revalidate = await request.post(`${baseURL}/api/revalidate?tag=posts`, { + headers: { 'x-revalidate-secret': revalidateSecret }, + }) + expect(revalidate.ok()).toBeTruthy() + await waitForHomeSectionState(request, 'absent', 30) // Plain URL on purpose: the test also pins the canonical home URL. await page.goto('/') await expect(page).toHaveURL(/\/$/) - await expect(page.locator('[data-home-section="contents"]')).toHaveCount(0) + await expectSectionCardCount(page.locator('[data-home-section="contents"]'), 0) await expect(page.getByText('A caminhada, em tempo real')).toHaveCount(0) const proof = await page.locator('[data-home-section="proof"]').boundingBox() @@ -1331,8 +1348,10 @@ test.describe('Campaign home content section', () => { const section = page.locator('[data-home-section="contents"]') await expect(section).toBeVisible() await expect(section.getByRole('link', { name: /E2e Artigo fallback IG/ })).toBeVisible() - await expect(section.getByText('E2e Post do muro')).toHaveCount(0) - await expect(section.getByRole('link', { name: /Seguir no Instagram/ })).toHaveCount(0) + // Same rendered-DOM class as the kill-switch test (OPS83 run #15): the + // server poll converged but the page served a stale body — poll the DOM. + await expectSectionCardCount(section.getByText('E2e Post do muro'), 0) + await expectSectionCardCount(section.getByRole('link', { name: /Seguir no Instagram/ }), 0) await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) @@ -1456,14 +1475,17 @@ test.describe('Campaign home content section', () => { ['E2e Artigo kill switch'], ['E2e Vídeo em destaque', 'YouTube →', 'E2e Post do muro', 'Seguir no Instagram'], ) + // The page can serve a heuristic-cached body that lags the server poll + // (OPS83 run #15): navigate fresh AND poll the rendered section DOM — + // the IG link is what failed there, scoped to the same payload. await gotoHomeFresh(page) const section = page.locator('[data-home-section="contents"]') await expect(section).toBeVisible() await expect(section.getByRole('link', { name: /E2e Artigo kill switch/ })).toBeVisible() - await expect(section.getByText('E2e Vídeo em destaque')).toHaveCount(0) - await expect(section.getByRole('link', { name: /YouTube →/ })).toHaveCount(0) - await expect(section.getByText('E2e Post do muro')).toHaveCount(0) - await expect(section.getByRole('link', { name: /Seguir no Instagram/ })).toHaveCount(0) + await expectSectionCardCount(section.getByText('E2e Vídeo em destaque'), 0) + await expectSectionCardCount(section.getByRole('link', { name: /YouTube →/ }), 0) + await expectSectionCardCount(section.getByText('E2e Post do muro'), 0) + await expectSectionCardCount(section.getByRole('link', { name: /Seguir no Instagram/ }), 0) await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/)