From d29b7903066fc69064310e4551e439996458c2b9 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 24 Aug 2026 15:23:44 -0300 Subject: [PATCH] =?UTF-8?q?chore(e2e):=20E2E-DEBT-S-GATE=20=E2=80=94=20ext?= =?UTF-8?q?ract=20waitForStreamSettled=20helper=20and=20migrate=2021=20sit?= =?UTF-8?q?es=20across=205=20specs=20(Closes=20#827)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-08-24-e2e-debt-s-gate.md | 1 + .../e2e-debt-gate-s-stream-settle-impl.md | 128 ++++++++++++++++++ tests/e2e/campaignMunicipalities.e2e.spec.ts | 41 +++--- ...mpaignMunicipalityDeliberation.e2e.spec.ts | 8 +- tests/e2e/campaignPeople.e2e.spec.ts | 9 +- .../e2e/campaignPermissionProfile.e2e.spec.ts | 6 +- tests/e2e/campaignUpdatesMobile.e2e.spec.ts | 25 ++-- tests/e2e/fixtures/campaignE2EFixtures.ts | 25 ++++ 8 files changed, 189 insertions(+), 54 deletions(-) create mode 100644 docs/changelog/2026-08-24-e2e-debt-s-gate.md create mode 100644 docs/plans/e2e-debt-gate-s-stream-settle-impl.md diff --git a/docs/changelog/2026-08-24-e2e-debt-s-gate.md b/docs/changelog/2026-08-24-e2e-debt-s-gate.md new file mode 100644 index 00000000..46679f97 --- /dev/null +++ b/docs/changelog/2026-08-24-e2e-debt-s-gate.md @@ -0,0 +1 @@ +**E2E-DEBT-S-GATE — consolidar o gate S: (stream settle) num helper compartilhado de fixtures (2026-08-24):** extração de `waitForStreamSettled` em `tests/e2e/fixtures/campaignE2EFixtures.ts` (default 15 s, pass-through de `options`) e migração de 21 call sites em 5 specs — campaignMunicipalities (11, incluindo o helper local `settleMunicipalityStream` removido), campaignPeople (4), campaignPermissionProfile (2), campaignMunicipalityDeliberation (1, helper local `settleStream` removido, timeout explícito preservado) e campaignUpdatesMobile (3, helper local `settleStream` removido, timeout explícito preservado). Polls complementares (leadershipLink, footer "1 município encontrado" 30 s, chips `toHaveCount(0)`) e outros gates (`waitForRouterSettled`, `meta-pixel`, `networkidle`) intocados — padrão OPS83 "settle + poll específico" preservado. Sem mudança de product code. diff --git a/docs/plans/e2e-debt-gate-s-stream-settle-impl.md b/docs/plans/e2e-debt-gate-s-stream-settle-impl.md new file mode 100644 index 00000000..f4c48816 --- /dev/null +++ b/docs/plans/e2e-debt-gate-s-stream-settle-impl.md @@ -0,0 +1,128 @@ +# Impl: E2E-DEBT-S-GATE — Consolidar o gate `#S:` de stream em um helper único + +Status: rascunho +Atualizado em: 2026-08-24 +Issue: #827 +Intenção: body da Issue #827 (kind chore; depends OPS83; P3) +Appetite restante: herdado — chore pequeno de refactor de e2e, sem mudança de product code + +## Leitura da intenção + +- **Outcome:** eliminar as ~18 cópias inline do gate `waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)` espalhadas por specs e2e, extraindo `waitForStreamSettled` para `tests/e2e/fixtures/campaignE2EFixtures.ts` e migrando os sites restantes no padrão validado OPS83 (settle + poll específico onde medido). Sem mudança de product code. +- **O que NÃO negociar:** nome do helper (`waitForStreamSettled`, fixado pela Issue); destino (`campaignE2EFixtures.ts`); gates de re-render de **outro tipo** ficam intactos (polls específicos de `leadershipLink`, footer `'1 município encontrado'` com 30s, `toHaveCount(0)` de chips, `waitForRouterSettled` dev-only, `script[id^="meta-pixel-"]`, `networkidle`); nenhum teste de produto/schema/CI é tocado. +- **O que reavaliar:** a intenção diz "7 specs"; o explorador confirmou **5** specs com o gate (campaignMunicipalities, campaignPeople, campaignPermissionProfile, campaignMunicipalityDeliberation, campaignUpdatesMobile) — o número real da migração é 5 specs / 21 call sites. Também se reavalia o timeout default do helper (ver Decisão 1) — a intenção não o fixa ("pese e decida documentado"). + +## Abordagem recomendada + +```mermaid +flowchart LR + A[gate inline #S: em 5 specs] --> B[waitForStreamSettled no fixture] + B --> C[3 helpers locais deletados] + B --> D[21 call sites migrados] + D --> E[grep guard: 0 ocorrências do padrão cru] + E --> F[5 specs afetados localmente + gate:fast] +``` + +**Opções consideradas:** A | B | C +**Recomendação:** A — helper único no fixture com default explícito de 15s e pass-through de `options` — porque é o destino fixado pela Issue, o fixture já é o dono dos helpers de página (depth check: 5 helpers de página exportados hoje — `expectPostResponse`, `checkRadixWhenHydrated`, `waitForRouterSettled`, `expectCampaignBiometricsReady`, `campaignPageChrome`), e o 15s está validado pelos dois helpers locais de OPS83 + pela convenção 15_000 do fixture (todos os helpers de espera usam 15s). + +### Decisões de engenharia (formato caro/barato) + +**Decisão 1 — assinatura e timeout default do helper.** + +```text +Opções: A | B | C +Recomendação: A — `waitForStreamSettled(page: Page, options?: { timeout?: number })` + → `page.waitForFunction(fn, undefined, options)` com default 15_000 + porque: (a) `waitForFunction` só aceita options na 3ª posição — o pass-through é + obrigatório, não cosmético; (b) os dois helpers locais de OPS83 (deliberation L14, + updatesMobile L16) já provam 15s suficiente para a classe de gate; (c) a convenção + do fixture é 15_000 (checkRadixWhenHydrated, expectCampaignBiometricsReady, + waitForRouterSettled); (d) default explícito é self-documenting. +Alternativas rejeitadas: + B — `waitForStreamSettled(page)` sem options, 15s hardcoded — porque o pass-through + é grátis (só a 3ª posição do waitForFunction) e perde a válvula de escape por site + para futuros flakies; reabrir o helper para adicionar options depois é rework. + C — sem timeout explícito (default Playwright 30s) — preservaria byte-a-byte o + comportamento dos 14 sites inline que hoje rodam a 30s, mas ignora o padrão + validado OPS83 (15s) e a convenção do fixture; default 30s implícito também + esconderia o budget do gate. +``` + +Efeito colateral deliberado: os 14 sites inline (8 em campaignMunicipalities, 4 em campaignPeople, 2 em campaignPermissionProfile) passam de 30s → 15s. Aceito porque o gate espera **commit de DOM** (escala de ms uma vez que o stream resolve — o shell `#S:` some junto com o último chunk RSC); 15s é budget comprovado na mesma classe de gate por 2 specs desde OPS83; o `options` permite override por site se algum dia precisar. Registrado em Riscos. + +**Decisão 2 — corpo do helper é cópia literal do gate validado.** + +```text +Opções: A | B +Recomendação: A — `page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0, undefined, options)` idêntico ao gate atual, com JSDoc explicando o mecanismo (shells transientes `#S:*` escondidos do stream RSC, origem OPS83, aviso "the generic S: gate alone is not enough" → polls complementares ficam nos call sites). +Alternativas rejeitadas: + B — reescrever como `expect(...).toPass`/locator — porque muda a semântica do gate + (waitForFunction avalia no DOM raiz, sem retry de assertion por locator) e qualquer + reescrita é uma mudança de comportamento não pedida num chore de extração. +``` + +**Decisão 3 — destino e estrutura do helper.** + +```text +Opções: A | B | C +Recomendação: A — `campaignE2EFixtures.ts`, junto dos outros helpers de página. +Alternativas rejeitadas: + B — arquivo novo `tests/e2e/fixtures/waitForStreamSettled.ts` — porque é pass-through + raso (depth check #2): um arquivo de 1 função para 1 call site de import é cerimônia + sem volatilidade; o fixture já é o módulo profundo de esperas. + C — `e2eTest.ts`/outro fixture — porque o destino está fixado pela Issue e os 5 specs + já importam de `campaignE2EFixtures.js`. +``` + +### Componentes / mudanças + +- **`tests/e2e/fixtures/campaignE2EFixtures.ts`**: novo export `waitForStreamSettled(page: Page, options?: { timeout?: number }): Promise` — JSDoc com o mecanismo do gate, origem OPS83, default 15s, aviso dos polls complementares. `Page` já é type-only import no arquivo (L4). Nada mais no fixture muda (ownership/session/setup intocados). +- **`tests/e2e/campaignMunicipalities.e2e.spec.ts`**: deletar helper local `settleMunicipalityStream` (L55–56); migrar 11 sites — 3 chamadas (L455/466/725) + 8 inline (L153/972/1243/1323/1398/1457/1561/1648); adicionar `waitForStreamSettled` ao bloco de import do fixture (L6–20). `Page` permanece usado por outros helpers locais. +- **`tests/e2e/campaignPeople.e2e.spec.ts`**: migrar 4 inline (L41/52/107/343); adicionar ao import do fixture (L1–7). +- **`tests/e2e/campaignPermissionProfile.e2e.spec.ts`**: migrar 2 inline (L97/115); import vira `{ expect, test, waitForStreamSettled }`. Os `waitForLoadState('networkidle')` (L27/47/62/77/93/113) são padrão distinto ANTES do gate — manter. +- **`tests/e2e/campaignMunicipalityDeliberation.e2e.spec.ts`**: deletar helper local `settleStream` (L14–17); migrar 1 chamada (L63); adicionar ao import do fixture (L4–10). `Page` permanece usado por `openFeedAs` (L57). +- **`tests/e2e/campaignUpdatesMobile.e2e.spec.ts`**: deletar helper local `settleStream` (L16–19); migrar 3 chamadas (L63/126/143); adicionar ao import (L4). `Page` permanece usado por `scrollFeedToBottom` (L47). +- **Migration:** sem migration (nenhuma mudança de schema). +- **Access / Consent:** n/a — sem product code. +- **UI:** n/a. + +### Dados → forma (se aplicável) + +n/a — nenhuma mudança de dados/forma; a forma é o código de teste. + +## Fases verificáveis + +1. **Helper + tracer bullet (spec menor)** — adicionar `waitForStreamSettled` ao fixture com JSDoc; migrar `campaignMunicipalityDeliberation` (1 call + helper local removido) e rodar esse spec isolado para validar o traço antes do bulk: `pnpm test:e2e --no-deps -- tests/e2e/campaignMunicipalityDeliberation.e2e.spec.ts`. +2. **Bulk municípios e pessoas** — `campaignMunicipalities` (11 sites + helper local removido) e `campaignPeople` (4 sites). +3. **Restante** — `campaignPermissionProfile` (2) e `campaignUpdatesMobile` (3 + helper local removido). +4. **Verificação** — grep guard: `grep -rn "querySelectorAll.*id\^=\"S:\"" tests/e2e` deve retornar **zero** ocorrências (nenhum site residual nem variante nova). Rodar os 5 specs afetados localmente (`pnpm test:e2e --no-deps --` com os 5 arquivos; usar `--workers=1` se necessário — limitação OPS72 #72 de seed paralelo) + `pnpm gate:fast` (lint/typecheck/unit — pega unused imports que a remoção dos helpers locais puder deixar). Pirâmide (OPS90): chore puro de e2e **não exige** unit/int novos — o nível certo de validação é o próprio e2e afetado + gates; o diff (só `tests/e2e/**`) classifica `selected` no CI (blast radius), nunca full. +5. **Changelog (OPS44)** — o fluxo de entrega pede entrada mesmo para chore: `docs/changelog/2026-08-24-e2e-debt-s-gate.md` (uma entrada no formato do agregado) + `pnpm changelog:build` + `pnpm changelog:check`. +6. **Entrega** — `pnpm gate:fast` na iteração; `pnpm push -u origin HEAD`; PR via `GITHUB_TOKEN= node scripts/github-pr.mjs --head --title "E2E-DEBT-S-GATE — " --body-file ` com `Closes #827`; incluir este `*-impl.md` no commit. + +## Rabbit holes / Não escopo (engenharia) + +- **Não** unificar os polls complementares (leadershipLink L727, footer `'1 município encontrado'` L1398/1561, chips `toHaveCount(0)` L454/465, `getByRole('row')` em people) — OPS83 validou "settle + poll específico"; cada um é gate de re-render de outro tipo e substituí-los seria mudança de comportamento fora do chore. +- **Não** refatorar `waitForRouterSettled` (OPS42, dev-only, outro mecanismo de quiescência) nem o gate `script[id^="meta-pixel-"]` (campaignHomePixel) — são outros gates, flagrados pelo explorador como fora do padrão `#S:`. +- **Não** criar teste unitário para o helper — `waitForFunction` só existe em browser; o nível certo é o próprio e2e (OPS90). +- **Não** tocar nada além do gate no fixture (ownership/cleanup/session code é região sensível; diff mínimo). +- **Não** "melhorar" o seletor (`div[id^="S:"]` → `[id^="S:"]` etc.) — cópia literal, ver Decisão 2. +- **Não** reabrir a contagem de specs da intenção (7 vs 5 reais) como trabalho extra — o explorador confirmou e o diff de verificação cobre. + +## Riscos e mitigação + +- **Timeout 30s → 15s em 14 sites inline** (municipalities 8, people 4, permissionProfile 2). Mitigação: gate espera commit de DOM (ms-scale), 15s validado na mesma classe por 2 specs OPS83 + convenção do fixture; se um site flakyar no CI, override por site via `options` (Decisão 1). Registrado no JSDoc do helper. +- **Site residual não migrado** (grep inicial achou 17 ocorrências cruas; migração mexe em 14 inline + 3 defs de helper). Mitigação: grep guard na Fase 4 exige 0 ocorrências de `querySelectorAll.*id\^="S:"`; diff do PR revisa isso. +- **Import unused deixado pela remoção dos helpers locais** — `Page` permanece usado em todos os 5 specs (verificado: deliberation `openFeedAs`, updatesMobile `scrollFeedToBottom`, municipalities/people outros helpers) — mas lint/knip do `gate:fast` pegaria qualquer resíduo; rodar na Fase 4. +- **Regressão silenciosa de gate** (editar site errado / trocar semântica na cópia). Mitigação: Decisão 2 fixa corpo literal; os 5 specs rodando na Fase 4 são o teste de comportamento; polls complementares no diff ficam intocados (revisão foca nisso). +- **Flakiness pré-existente mascarada** — specs afetados têm flakies históricos (OPS83 verify #15). Mitigação: rodar os 5 localmente e separar falha do diff de falha pré-existente antes de escalar para o pipeline. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto — gate `#S:` único (`waitForStreamSettled`), 21 call sites migrados, padrão OPS83 preservado, zero product code +- [x] Invariantes AGENTS/engineering-standards — sem schema/access/LGPD/URL; helpers no módulo dono (fixture), sem twin +- [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 (timeout/default, corpo do helper, destino); (2) cabe no appetite (chore mecânico, 1 helper + 21 sites); (3) rabbit holes nomeados; (4) depth check reusa o fixture dono dos helpers de página; (5) intenção satisfeita sem reescrever o outcome. diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index a0b47d79..4abba4ad 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -16,6 +16,7 @@ import { mintCampaignSession, seedCampaignSession, test, + waitForStreamSettled, type CampaignE2EOwnership, type CampaignSessionUser, } from './fixtures/campaignE2EFixtures.js' @@ -45,16 +46,6 @@ 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 @@ -150,7 +141,7 @@ test.describe('Municípios — jornadas por papel', () => { // C106 — the editar page streams heavy content (detail view model + // advisor options + stateDeputy options); the shell's `S:` copies must // settle before the advisor checkbox option can be reliably checked. - await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) + await waitForStreamSettled(page) await page.getByLabel(`${advisor.name} `, { exact: false }).check() await page.getByRole('button', { name: 'Salvar assessores' }).click() await expect(page.getByText('Assessores atualizados.')).toBeVisible() @@ -443,7 +434,7 @@ test.describe('Municípios — jornadas por papel', () => { // Dobradinha by contact name → chip + only municipality A remains. await omnibox.fill(deputyName) await listbox.getByRole('option', { name: new RegExp(`^${deputyName} \\(BR176\\)$`) }).click() - await settleMunicipalityStream(page) + await waitForStreamSettled(page) await expect(removeChip(`Dobradinha: ${deputyName} (BR176)`)).toBeVisible() await expect(tableRows).toHaveCount(1) await expect(page.getByRole('link', { name: municipalityA.name, exact: true })).toBeVisible() @@ -453,24 +444,24 @@ test.describe('Municípios — jornadas por papel', () => { // 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) + await waitForStreamSettled(page) // Partido by acronym → the same single municipality (its only dobradinha). await omnibox.fill('BR176') await listbox.getByRole('option', { name: 'BR176', exact: true }).click() - await settleMunicipalityStream(page) + await waitForStreamSettled(page) await expect(removeChip('Partido: BR176')).toBeVisible() await expect(tableRows).toHaveCount(1) await expect(page.getByRole('link', { name: municipalityA.name, exact: true })).toBeVisible() await removeChip('Partido: BR176').click() await expect(removeChip('Partido: BR176')).toHaveCount(0) - await settleMunicipalityStream(page) + await waitForStreamSettled(page) // Liderança by contact name → municipality B only. await omnibox.fill(leadership.contactName) await listbox.getByRole('option', { name: new RegExp(`^${leadership.contactName}$`) }).click() - await settleMunicipalityStream(page) + await waitForStreamSettled(page) await expect(removeChip(`Liderança: ${leadership.contactName}`)).toBeVisible() await expect(tableRows).toHaveCount(1) await expect(page.getByRole('link', { name: municipalityB.name, exact: true })).toBeVisible() @@ -725,7 +716,7 @@ test.describe('Municípios — jornadas por papel', () => { // enough (observed in the OPS83 verify run #15): poll until the specific // link lands in the DOM, then assert visibility (`.first()` only disambiguates // if a duplicate ever appears — the count poll is the real gate). - await settleMunicipalityStream(page) + await waitForStreamSettled(page) const leadershipLink = page.getByRole('link', { name: contact.name }) await expect.poll(() => leadershipLink.count(), { timeout: 15_000 }).toBeGreaterThan(0) await expect(leadershipLink.first()).toBeVisible() @@ -737,7 +728,7 @@ test.describe('Municípios — jornadas por papel', () => { await page.goto(`${campaign.baseURL}/campanha/municipios/${administered.slug}`) // The overview pledges panel streams under load; the `Média` estimate // field can land after `goto` settles (OPS83 run #16: fill timed out). - await settleMunicipalityStream(page) + await waitForStreamSettled(page) const estimateInput = page.getByLabel('Média', { exact: true }) await expect.poll(() => estimateInput.count(), { timeout: 20_000 }).toBeGreaterThan(0) await estimateInput.fill('90') @@ -977,7 +968,7 @@ test.describe('Municípios — filtro e cards sem moldura no celular (B184)', () await page.goto(`${campaign.baseURL}/campanha/municipios`) // C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell; // strict-mode locators below would match BOTH copies while it settles. - await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) + await waitForStreamSettled(page) // The mobile standard hides the field label; the input keeps its name via // aria-label, and the filter form is a borderless sticky bar. @@ -1248,7 +1239,7 @@ test.describe('Municípios — barra colada e card denso (B196)', () => { await campaign.login(page, coordinator.email!, coordinator.password) await page.goto(`${campaign.baseURL}/campanha/municipios`) // C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell. - await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) + await waitForStreamSettled(page) const topBar = mobileTopBar(page) const omniboxInput = page.getByRole('combobox', { name: 'Filtrar municípios' }) @@ -1328,7 +1319,7 @@ test.describe('Municípios — barra colada e card denso (B196)', () => { ) // C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell; // geometry assertions must wait it out or they measure the stream copy. - await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) + await waitForStreamSettled(page) const card = cardFor(page, municipality.name) await expect(card).toBeVisible() @@ -1403,7 +1394,7 @@ test.describe('Municípios — barra colada e card denso (B196)', () => { // Five advisors: all on ONE row, each covering the start of the next — // the overlap is proportional to the count (no wrap, no cap, no "…"). await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(crowded.name)}`) - await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) + await waitForStreamSettled(page) const crowdedCard = cardFor(page, crowded.name) await expect(crowdedCard).toBeVisible() // The completion footer proves the filtered list finished streaming — the @@ -1462,7 +1453,7 @@ test.describe('Municípios — barra colada e card denso (B196)', () => { // Two advisors: same single row, a slight deliberate overlap — a pile at // the left, not one avatar in each corner (B200). await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(sparse.name)}`) - await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) + await waitForStreamSettled(page) const sparseCard = cardFor(page, sparse.name) await expect(sparseCard).toBeVisible() // Same B196 stream-latency flake class as the `crowdedCard` footer above: @@ -1566,7 +1557,7 @@ test.describe('Municípios — encaixes dos cards (B200)', () => { await page.goto( `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`, ) - await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) + await waitForStreamSettled(page) // The bar's own `pt-1` applying proves the stylesheet reached the page — // before that the form renders unstyled and measures ~79px (visible label). const form = page @@ -1653,7 +1644,7 @@ test.describe('Municípios — encaixes dos cards (B200)', () => { await campaign.login(page, coordinator.email!, coordinator.password) await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(crowded.name)}`) - await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) + await waitForStreamSettled(page) const card = cardFor(page, crowded.name) await expect(card).toBeVisible() // The completion footer proves the filtered list finished streaming — the diff --git a/tests/e2e/campaignMunicipalityDeliberation.e2e.spec.ts b/tests/e2e/campaignMunicipalityDeliberation.e2e.spec.ts index a92cc9e0..a358324a 100644 --- a/tests/e2e/campaignMunicipalityDeliberation.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalityDeliberation.e2e.spec.ts @@ -6,16 +6,12 @@ import { mintCampaignSession, seedCampaignSession, test, + waitForStreamSettled, type CampaignE2EFixture, } from './fixtures/campaignE2EFixtures.js' const DESKTOP_VIEWPORT = { width: 1280, height: 900 } -const settleStream = (page: Page) => - page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0, undefined, { - timeout: 15_000, - }) - type DeliberationSeed = { coordinator: CampaignUser & { password: string } advisor: CampaignUser & { password: string } @@ -60,7 +56,7 @@ const openFeedAs = async ( await seedCampaignSession(context, campaign.baseURL, token) await page.setViewportSize(DESKTOP_VIEWPORT) await page.goto(`${campaign.baseURL}/campanha/atualizacoes`) - await settleStream(page) + await waitForStreamSettled(page, { timeout: 15_000 }) } test.describe('C88 — deliberação na atualização', () => { diff --git a/tests/e2e/campaignPeople.e2e.spec.ts b/tests/e2e/campaignPeople.e2e.spec.ts index cb452204..c9f1c7e7 100644 --- a/tests/e2e/campaignPeople.e2e.spec.ts +++ b/tests/e2e/campaignPeople.e2e.spec.ts @@ -3,6 +3,7 @@ import { expect, expectPostResponse, test, + waitForStreamSettled, } from './fixtures/campaignE2EFixtures.js' import type { CampaignUser } from '../../src/payload-types.js' @@ -38,7 +39,7 @@ test.describe('Pessoas — lista unificada', () => { // `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 waitForStreamSettled(page) 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() @@ -49,7 +50,7 @@ test.describe('Pessoas — lista unificada', () => { 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 waitForStreamSettled(page) await expect(page.getByRole('row', { name: new RegExp(contactName) })).toBeVisible() }) @@ -104,7 +105,7 @@ test.describe('Pessoas — lista unificada', () => { 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 waitForStreamSettled(page) await page.getByRole('link', { name: contactName, exact: true }).click() await expect(page).toHaveURL(new RegExp(`/campanha/pessoas/${contactId}$`)) @@ -340,7 +341,7 @@ test.describe('Pessoas — lista unificada', () => { 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 waitForStreamSettled(page) 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/campaignPermissionProfile.e2e.spec.ts b/tests/e2e/campaignPermissionProfile.e2e.spec.ts index d4a1e811..ed30410e 100644 --- a/tests/e2e/campaignPermissionProfile.e2e.spec.ts +++ b/tests/e2e/campaignPermissionProfile.e2e.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from './fixtures/campaignE2EFixtures.js' +import { expect, test, waitForStreamSettled } from './fixtures/campaignE2EFixtures.js' /** * C142 — advisor permission profiles respect the UI surface: @@ -94,7 +94,7 @@ test.describe('Advisor permission profiles (C142)', () => { // C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell; // the FAB lives in the streamed shell and won't be present until the // `S:` copies settle. - await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) + await waitForStreamSettled(page) // The FAB should be present (carteira scope = write allowed). await expect(page.locator('[data-slot="campaign-quick-actions-fab"]')).toBeVisible() @@ -112,7 +112,7 @@ test.describe('Advisor permission profiles (C142)', () => { await page.goto('/campanha/municipios') await page.waitForLoadState('networkidle') // C106 — same shell-settle requirement as the carteira sibling. - await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) + await waitForStreamSettled(page) // The FAB must be present for a tudo advisor. await expect(page.locator('[data-slot="campaign-quick-actions-fab"]')).toBeVisible() diff --git a/tests/e2e/campaignUpdatesMobile.e2e.spec.ts b/tests/e2e/campaignUpdatesMobile.e2e.spec.ts index bb1fd33c..65886de2 100644 --- a/tests/e2e/campaignUpdatesMobile.e2e.spec.ts +++ b/tests/e2e/campaignUpdatesMobile.e2e.spec.ts @@ -1,23 +1,16 @@ import type { Page } from '@playwright/test' import type { CampaignUser } from '../../src/payload-types.js' -import { expect, test, type CampaignE2EFixture } from './fixtures/campaignE2EFixtures.js' +import { + expect, + test, + waitForStreamSettled, + type CampaignE2EFixture, +} from './fixtures/campaignE2EFixtures.js' const MOBILE_VIEWPORT = { width: 390, height: 844 } const DESKTOP_VIEWPORT = { width: 1280, height: 900 } -/** - * Dynamic campaign pages stream their content (awaited `searchParams`), so - * right after `page.goto` the DOM can hold a transient hidden `#S:*` copy of - * the page shell while the stream settles. All class-based locators below - * filter `visible: true` so assertions never match that transient duplicate - * (role-based locators already exclude it). - */ -const settleStream = (page: Page) => - page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0, undefined, { - timeout: 15_000, - }) - const seedUpdates = async ( campaign: CampaignE2EFixture, ): Promise => { @@ -60,7 +53,7 @@ test.describe('C106 — atualizações mobile sem moldura', () => { await page.setViewportSize(MOBILE_VIEWPORT) await campaign.login(page, coordinator.email!, coordinator.password) await page.goto(`${campaign.baseURL}/campanha/atualizacoes`) - await settleStream(page) + await waitForStreamSettled(page, { timeout: 15_000 }) const strip = page.getByRole('search') const feedList = page.locator('ul').filter({ hasText: 'Fato de campo C106 #8' }).first() @@ -123,7 +116,7 @@ test.describe('C106 — atualizações mobile sem moldura', () => { await page.setViewportSize(MOBILE_VIEWPORT) await campaign.login(page, coordinator.email!, coordinator.password) await page.goto(`${campaign.baseURL}/campanha/atualizacoes`) - await settleStream(page) + await waitForStreamSettled(page, { timeout: 15_000 }) await page.getByRole('combobox', { name: 'Filtrar atualizações' }).fill('zzz') await page.getByRole('combobox', { name: 'Filtrar atualizações' }).press('Enter') @@ -140,7 +133,7 @@ test.describe('C106 — atualizações mobile sem moldura', () => { await page.setViewportSize(DESKTOP_VIEWPORT) await campaign.login(page, coordinator.email!, coordinator.password) await page.goto(`${campaign.baseURL}/campanha/atualizacoes`) - await settleStream(page) + await waitForStreamSettled(page, { timeout: 15_000 }) const strip = page.getByRole('search') diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index 6f6d4d3d..c3a2e0c7 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -554,6 +554,31 @@ export const checkRadixWhenHydrated = async (page: Page, label: string) => { }).toPass({ timeout: 15_000 }) } +/** + * Waits for dynamic campaign pages to commit their RSC stream by polling + * until the browser has no transient hidden `#S:*` shell copies left. + * + * Every campaign page that loads via `await searchParams` can hold a + * transient hidden `div[id^="S:*"]` in the DOM while the RSC chunks land; + * class-based locators and click actions that race the stream see + * the stale copy (OPS83 verify #15 — observed flake at 4 workers). + * + * **The generic `S:` gate alone is not enough for some flows** (OPS83 + * verified the dossiê tab needs a specific DOM poll after the settle). + * Pair with a focused poll when the assertion is on a specific element + * (e.g. `expect.poll(() => link.count()).toBeGreaterThan(0)`) rather + * than relying on the settle gate to prove the element exists. + * + * Default timeout: 15 000 ms (matching the OPS83-validated settle + * budget for campaign pages). Override via `options` for heavier pages. + */ +export const waitForStreamSettled = (page: Page, options?: { timeout?: number }) => + page.waitForFunction( + () => document.querySelectorAll('div[id^="S:"]').length === 0, + undefined, + options, + ) + /** * OPS42 — waits out a pending dev-mode router navigation before the test * interacts. Next dev's hydration/router churn can commit a layout remount