mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
chore(e2e): E2E-DEBT-S-GATE — extract waitForStreamSettled helper and migrate 21 sites across 5 specs (Closes #827)
This commit is contained in:
parent
8d26c5d1d1
commit
d29b790306
8 changed files with 189 additions and 54 deletions
1
docs/changelog/2026-08-24-e2e-debt-s-gate.md
Normal file
1
docs/changelog/2026-08-24-e2e-debt-s-gate.md
Normal 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.
|
||||
128
docs/plans/e2e-debt-gate-s-stream-settle-impl.md
Normal file
128
docs/plans/e2e-debt-gate-s-stream-settle-impl.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue