chore(e2e): E2E-DEBT-S-GATE — extract waitForStreamSettled helper and migrate 21 sites across 5 specs (Closes #827)

This commit is contained in:
Francisco Solla 2026-08-24 15:23:44 -03:00
parent 8d26c5d1d1
commit d29b790306
8 changed files with 189 additions and 54 deletions

View file

@ -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.

View file

@ -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<void>` — 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=<PAT> node scripts/github-pr.mjs --head <branch> --title "E2E-DEBT-S-GATE — <título>" --body-file <arquivo>` 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.

View file

@ -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

View file

@ -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', () => {

View file

@ -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.

View file

@ -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()

View file

@ -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<CampaignUser & { password: string }> => {
@ -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')

View file

@ -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