teqo-backup/tests/e2e/campaignSollinhaContext.e2e.spec.ts
Francisco Solla 30dac18872 refactor(tests): B194 — extract shared SSE mock helper and migrate legacy chat specs to v7
Extract sollinhaChatStreamBody + mockSollinhaChat into campaignE2EFixtures.ts
(canonical v7 wire format), reuse across all 6 chat e2e specs and migrate the
two legacy specs (transcribe, openingChips) from the format that fails the v7
client schema. Closes #556
2026-08-24 22:22:08 -03:00

348 lines
14 KiB
TypeScript

import type { Page } from '@playwright/test'
import {
expect,
mockSollinhaChat,
sollinhaChatStreamBody,
test,
waitForRouterSettled,
} from './fixtures/campaignE2EFixtures.js'
const SESSION_KEY = 'teqo:campaign:sollinha-chat-session'
const DEFAULT_REPLY = 'Resposta mockada com [Municípios](/campanha/municipios).'
const EXTERNAL_REPLY = 'Resposta mockada com [portal da saúde](https://www.saude.ba.gov.br/).'
/**
* Mock whose FIRST request replies immediately and every later request hangs
* until the test calls `release()` — a deterministic window in which the chat
* is still busy (`status !== 'ready'`) with a reply blocked, with no stream
* timing to race against.
*/
const gatedMockAiChat = (page: Page) => {
let resolveGate: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
resolveGate = resolve
})
let requests = 0
void page.route('**/campanha/api/ai-chat', async (route) => {
const index = requests
requests += 1
if (index >= 1) await gate
try {
await route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: sollinhaChatStreamBody(DEFAULT_REPLY),
})
} catch {
// An early failure may end the test before `release()` — fulfilling on a
// closed page rejects, and the test's own error is the signal to read.
}
})
return {
release: () => {
resolveGate?.()
},
requests: () => requests,
}
}
const MESSAGE = 'Mensagem que sobrevive ao reload'
const SECOND_MESSAGE = 'Segunda pergunta'
const openChatAndSend = async (page: Page) => {
await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 })
// OPS42 — dev-only settle before interacting (see `waitForRouterSettled`).
await waitForRouterSettled(page)
const input = page.getByRole('textbox', { name: 'Pergunte para o Sollinha...' })
await input.fill(MESSAGE)
await input.press('Enter')
await expect(page.getByText(MESSAGE)).toBeVisible({ timeout: 20_000 })
}
/** The chat has settled once the mic button re-enables (busy → ready). */
const waitForChatSettled = async (page: Page) => {
await expect(
page.getByRole('button', { name: 'Falar pergunta (voz)' }).filter({ visible: true }),
).toBeEnabled({ timeout: 20_000 })
}
/** Opens the mobile drawer and waits for it to cover the page. */
const openMobileDrawer = async (page: Page) => {
const drawer = page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })
// OPS42 — the trigger is part of the layout that dev-mode router churn can
// remount after load; a click in that window is a silent no-op (dev-only).
await waitForRouterSettled(page)
await page.getByRole('button', { name: 'Sollinha — Assistente virtual' }).click()
await expect(drawer).toBeVisible({ timeout: 20_000 })
return drawer
}
/** Raw stored session, or null when nothing (valid) is in the tab's storage. */
const storedSession = (page: Page) =>
page.evaluate((key) => {
const raw = sessionStorage.getItem(key)
if (!raw) return null
return JSON.parse(raw) as { messages?: unknown[]; open?: boolean }
}, SESSION_KEY)
test.describe('B188 — contexto da conversa persiste na sessão da janela/tab', () => {
test.beforeEach(async ({ page }) => {
test.slow()
await mockSollinhaChat(page, DEFAULT_REPLY)
})
test('reload na mesma aba restaura a conversa', async ({ page, campaign }) => {
const user = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Contexto Janela Coordenador'),
})
await campaign.login(page, user.email!, user.password)
await page.setViewportSize({ width: 1280, height: 800 })
await page.goto('/campanha')
await openChatAndSend(page)
await waitForChatSettled(page)
// Deterministic gate: the persist effect must have landed before reload.
await expect.poll(async () => (await storedSession(page))?.messages?.length).toBeGreaterThan(0)
await page.reload()
await expect(page.getByText(MESSAGE)).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('Olá! Eu sou o Sollinha')).not.toBeVisible()
})
test('link da resposta navega sem recarregar a página', async ({ page, campaign }) => {
const user = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Contexto Janela Coordenador'),
})
await campaign.login(page, user.email!, user.password)
await page.setViewportSize({ width: 1280, height: 800 })
await page.goto('/campanha')
await openChatAndSend(page)
await waitForChatSettled(page)
// A full browser navigation fires `load`; client-side routing does not.
let loadEvents = 0
page.on('load', () => {
loadEvents += 1
})
await page.locator('#ai-chat-panel').getByRole('link', { name: 'Municípios' }).click()
await expect(page).toHaveURL(/\/campanha\/municipios/)
expect(loadEvents).toBe(0)
// The conversation is still mounted with the layout — nothing reloaded.
await expect(page.getByText(MESSAGE)).toBeVisible({ timeout: 20_000 })
})
test('drawer mobile aberto volta aberto após reload', async ({ page, campaign }) => {
const user = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Contexto Janela Coordenador'),
})
// Mobile from the start: a desktop login would auto-open the chat (B167
// settle), persist `open: true` and make the drawer open "by itself" here.
await page.setViewportSize({ width: 500, height: 800 })
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha')
await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0)
await openMobileDrawer(page)
// The open flag must have been persisted before reload.
await expect.poll(async () => (await storedSession(page))?.open).toBe(true)
await page.reload()
await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toBeVisible({
timeout: 20_000,
})
})
test('visita mobile nova permanece fechada após reload (sem chat saltando)', async ({
page,
campaign,
}) => {
const user = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Contexto Janela Coordenador'),
})
// Mobile from the start (see the sibling test): the desktop settle must
// never have run in this tab, so no `open: true` is ever persisted.
await page.setViewportSize({ width: 500, height: 800 })
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha')
await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0)
await page.reload()
await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Sollinha — Assistente virtual' })).toBeVisible({
timeout: 20_000,
})
})
test('settle do desktop não vaza chat aberto para a visita mobile da mesma aba (OPS22)', async ({
page,
campaign,
}) => {
const user = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Contexto Janela Coordenador'),
})
// Desktop login: the B167 settle opens the chat and the persist effect
// writes `open: true` (settle-originated) for the tab.
await campaign.login(page, user.email!, user.password)
await expect.poll(async () => (await storedSession(page))?.open).toBe(true)
// A mobile page in the SAME tab must not restore that settle-originated
// open: the drawer would cover the content and aria-hide it (the OPS22
// regression that turned the agenda-mobile e2e red). The chat stays
// reachable through its button.
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/campanha/agenda')
await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Sollinha — Assistente virtual' })).toBeVisible({
timeout: 20_000,
})
})
})
test.describe('B198 — link de resposta fecha o drawer mobile ao navegar no mesmo tab', () => {
test.beforeEach(async ({ page }) => {
test.slow()
await mockSollinhaChat(page, DEFAULT_REPLY)
})
test('link interno: drawer fecha no toque e a navegação segue no mesmo tab', async ({
page,
campaign,
}) => {
const user = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Drawer Link Interno'),
})
await page.setViewportSize({ width: 500, height: 800 })
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha')
const drawer = await openMobileDrawer(page)
await openChatAndSend(page)
await waitForChatSettled(page)
// A full browser navigation fires `load`; client-side routing does not.
let loadEvents = 0
page.on('load', () => {
loadEvents += 1
})
await drawer.getByRole('link', { name: 'Municípios' }).click()
await expect(page).toHaveURL(/\/campanha\/municipios/)
await expect(drawer).toHaveCount(0)
expect(loadEvents).toBe(0)
// The navigation-originated close persists `open: false` — a reload never
// reopens the drawer on its own.
await page.reload()
await expect(drawer).toHaveCount(0)
})
test('link externo: abre em nova aba e o drawer permanece aberto', async ({ page, campaign }) => {
// Last-registered route wins (LIFO) — overrides the beforeEach mock.
await mockSollinhaChat(page, EXTERNAL_REPLY)
// The popup would hit the real site; fulfill it so CI stays deterministic.
await page
.context()
.route('**://saude.ba.gov.br/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/html', body: '<title>ok</title>' }),
)
const user = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Drawer Link Externo'),
})
await page.setViewportSize({ width: 500, height: 800 })
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha')
const drawer = await openMobileDrawer(page)
await openChatAndSend(page)
await waitForChatSettled(page)
const popupPromise = page.waitForEvent('popup')
await drawer.getByRole('link', { name: 'portal da saúde' }).click()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded')
await popup.close()
// The origin tab keeps the conversation reachable — the drawer stays.
await expect(drawer).toBeVisible()
})
})
test.describe('B199 — fechar o drawer durante streaming persiste fechado no reload', () => {
test.beforeEach(async ({ page }) => {
test.slow()
await mockSollinhaChat(page, DEFAULT_REPLY)
})
test('fechar mid-stream grava open:false e o reload não reabre o drawer', async ({
page,
campaign,
}) => {
// Last-registered route wins (LIFO) — overrides the beforeEach mock. The
// first exchange replies immediately; the second stays blocked until
// `release()`, so the close provably lands while the chat is busy.
const gated = gatedMockAiChat(page)
const user = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Fechar Mid-Stream'),
})
await page.setViewportSize({ width: 500, height: 800 })
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha')
const drawer = await openMobileDrawer(page)
await openChatAndSend(page)
await waitForChatSettled(page)
// The stale `open: true` the bug would leave orphaned in the storage.
await expect.poll(async () => (await storedSession(page))?.open).toBe(true)
// Second exchange — the gated reply keeps the chat busy (status !== 'ready').
const input = page.getByRole('textbox', { name: 'Pergunte para o Sollinha...' })
await input.fill(SECOND_MESSAGE)
await input.press('Enter')
await expect(page.getByText(SECOND_MESSAGE)).toBeVisible({ timeout: 20_000 })
await expect(
page.getByRole('button', { name: 'Falar pergunta (voz)' }).filter({ visible: true }),
).toBeDisabled({ timeout: 20_000 })
// Close while the reply is still blocked: the persist must not wait for
// the settle, or a reload would restore the stale `open: true`.
await drawer.getByRole('button', { name: 'Fechar' }).click()
await expect(drawer).toHaveCount(0)
await expect.poll(async () => (await storedSession(page))?.open).toBe(false)
// Let the reply land: the settle still persists the whole conversation.
gated.release()
await expect.poll(async () => (await storedSession(page))?.messages?.length).toBe(4)
// Both exchanges went through the gated route — the LIFO override worked.
expect(gated.requests()).toBe(2)
// Reload: no ghost drawer, conversation restored.
await page.reload()
await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0)
// The FAB is SSR'd; a click that lands before hydration is a silent no-op
// (B13/B17 class) — retry until the drawer actually opens.
const fab = page.getByRole('button', { name: 'Sollinha — Assistente virtual' })
await expect(async () => {
await fab.click({ timeout: 1_000 })
await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toBeVisible(
{ timeout: 1_000 },
)
}).toPass({ timeout: 15_000 })
await expect(page.getByText(MESSAGE)).toBeVisible({ timeout: 20_000 })
await expect(page.getByText(SECOND_MESSAGE)).toBeVisible()
})
})