Merge pull request #702 from fsolla/B199-sollinha-fechar-o-drawer-durante-streaming-pode-reabri-
fix(B199): fechar o drawer mid-stream persiste open:false imediato — reload não ressuscita
This commit is contained in:
commit
4ad644d8ca
4 changed files with 328 additions and 17 deletions
92
docs/plans/e2e-dev-router-quiescencia.md
Normal file
92
docs/plans/e2e-dev-router-quiescencia.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# OPS42: e2e dev — navegação RSC do login commita tarde e remonta o layout (interação silenciosamente perdida)
|
||||
|
||||
Status: registrado
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #691
|
||||
Priority: P3
|
||||
Model: composer-2.5
|
||||
Appetite: ~0,5 dia eng — um helper de "quiescência do router" no fixture
|
||||
|
||||
## Intenção
|
||||
|
||||
Em dev mode (e2e local, compile a frio), a navegação RSC do login pode commitar
|
||||
**~1,8 s depois** do `goto('/campanha')` do teste: o fixture resolve o
|
||||
`waitForURL` no redirect otimista, o teste segue para `goto` + interação
|
||||
imediata, e a payload RSC do redirect — ainda em voo — commit por cima,
|
||||
**remontando o subtree do layout** (provider do chat novo, textarea novo com
|
||||
`defaultValue=''`). A interação (fill + Enter) cai no textarea velho e é
|
||||
silenciosamente perdida: input volta a `''`, o envio nunca dispara, o teste
|
||||
estoura em `toBeVisible`. CI (prod mode) é imune — por isso main está verde.
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona:** agente rodando e2e local em dev mode com a máquina sob carga
|
||||
(worktrees paralelos compilando).
|
||||
- **Fluxo quebrado:** login → `page.goto('/campanha')` → `'Olá! Eu sou o
|
||||
Sollinha'` visível → `fill` + `Enter` → nada acontece; o teste falha com o
|
||||
input vazio e "Enviar" disabled, aparentemente sem causa.
|
||||
- **Fluxo desejado:** o teste só interage depois que o router estabilizou
|
||||
(nenhuma framenav/navegação RSC pendente), ou a interação sobrevive à
|
||||
remontagem.
|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- Interagir com a página em dev mode nunca mais perde o fill/Enter para uma
|
||||
remontagem tardia do layout.
|
||||
- Sem impacto em prod mode (CI) — o helper é dev-only ou inócuo em prod.
|
||||
- Precedentes a seguir: `checkRadixWhenHydrated` (fixture, retry até o estado
|
||||
certo) e o hardening OPS30 (login resiliente a compile frio).
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
Dados: N/A — infraestrutura de e2e (fixture), sem servidor/banco.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Área provável:** `tests/e2e/fixtures/campaignE2EFixtures.ts` — o `login`
|
||||
resolve no `waitForURL` (redirect otimista) antes do commit RSC; e/ou um
|
||||
helper `waitForRouterSettled(page)` chamado após `goto` nos journeys que
|
||||
interagem logo após o load (espera por uma janela de ~500 ms sem
|
||||
`framenavigated`).
|
||||
- **Evidência da sessão (B199, 2026-08-11):** em dev mode sob carga, o teste
|
||||
B188 "reload na mesma aba" falhou 5/5 com esse mecanismo: `FRAMENAV
|
||||
/campanha` (sem request document) ~1,0–1,9 s após o `HELLO`, textarea `ta2`
|
||||
criada com `defaultValue=''` no instante do fill, zero writes de `.value` no
|
||||
textarea (React nunca commitou o valor digitado — o estado do useChat
|
||||
resetou na remontagem do provider).
|
||||
- **Risco:** esperar demais (networkidle nunca chega em dev com HMR ativo) —
|
||||
a janela de quiescência deve ser baseada em **ausência de framenav**, não em
|
||||
network idle.
|
||||
|
||||
## Dependências
|
||||
|
||||
- #586 (OPS30 — hardening e2e dev: login resiliente a compile frio) — mesma
|
||||
família de robustez; destrava sozinha quando o pai flipar `done`.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Consertar o comportamento do Next dev (a remontagem em si é da framework).
|
||||
- O flake de `googleCalendarSync` sob carga (#686 já registra).
|
||||
- Mudanças em prod mode/CI (imunes por construção).
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **Espera longa demais por teste:** multiplicar o tempo de cada spec em dev.
|
||||
**Corte:** o helper só espera a quiescência (janela sem framenav), não
|
||||
network idle; e só é chamado onde o journey interage imediatamente após o
|
||||
load.
|
||||
- **Camuflar falha real:** esperar quiescência pode mascarar um problema de
|
||||
app. **Corte:** o helper tem timeout curto (fallback) e a falha original do
|
||||
app continua aparecendo no assertion seguinte.
|
||||
|
||||
## Questões em aberto (produto)
|
||||
|
||||
- Nenhuma — é infraestrutura de teste.
|
||||
|
||||
## Referências
|
||||
|
||||
- Evidência completa da sessão B199 (mensagens de debug `TMPDEBUG2–8` no
|
||||
worktree B199, 2026-08-11): `NAVREQ/FRAMENAV/LOAD` timestamps, `SET
|
||||
defaultValue ta2=""`, setter de `value` nunca chamado.
|
||||
- `tests/e2e/campaignSollinhaContext.e2e.spec.ts` (B188 "reload na mesma aba"
|
||||
é o teste que flakeia), `tests/e2e/fixtures/campaignE2EFixtures.ts`.
|
||||
84
docs/plans/sollinha-close-mid-stream-persist-impl.md
Normal file
84
docs/plans/sollinha-close-mid-stream-persist-impl.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Impl: Sollinha: fechar o drawer durante streaming pode reabri-lo num reload (race de persistência)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #672
|
||||
Intenção: docs/plans/sollinha-close-mid-stream-persist.md
|
||||
Appetite restante: herdado (~0,5 dia eng; um ajuste no persist effect do provider)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** fechar o drawer em qualquer fase do turno (inclusive mid-stream) persiste `open: false` de imediato — um reload antes do settle não ressuscita o drawer; a conversa em andamento continua persistindo no settle normal.
|
||||
- **O que NÃO negociar:** o mecanismo de restore/`openBy` (OPS22/B188) fica intacto — o fix é só no write de fechamento; mensagens mid-stream nunca são persistidas (reload mid-stream perde só o turno em voo, corte documentado de B188); nada de migration/servidor/Consent.
|
||||
- **O que reavaliar:** a hipótese da intenção ("efeito separado só para `open`, sem gate de status, reusando `writeSollinhaChatSession` com as mensagens do último commit") está correta; o refinamento é o `status === 'ready'` skip no efeito novo para não duplicar o write que o efeito de settle já faz (ver D1).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph provider [CampaignAISidebarContext]
|
||||
R[restore no mount] --> S1[settle effect<br/>status ready: escreve msgs + open<br/>+ atualiza settledMessagesRef]
|
||||
O[open effect<br/>open mudou + status != ready:<br/>escreve settledMessagesRef + open]
|
||||
end
|
||||
S1 -.->|snapshot msgs settle-completas| REF[settledMessagesRef]
|
||||
O -->|write imediato de open| W[writeSollinhaChatSession]
|
||||
S1 --> W
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) efeito separado só para `open`, sem gate de status, gravando com o snapshot de mensagens do último settle | B) persistir tudo sem gate de status num efeito único | C) efeito separado só para fechamento (`open === false`)
|
||||
**Recomendação:** **A** com um refinamento — o efeito novo pula quando `status === 'ready'`, porque o efeito de settle já escreve tudo (mensagens + `open`) nesse estado; sem o skip, um `open` mudado em repouso dispararia dois writes no mesmo commit (um com snapshot velho, um com live — correto por "último write vence", mas redundante e dependente da ordem dos efeitos). O resultado cobre o aceite: fechar mid-stream escreve `open: false` com o snapshot de mensagens settle-completas (nunca um stream pela metade).
|
||||
**Rejeitadas:** **B** porque gravaria mensagens mid-stream no storage (o rabbit hole que a intenção nomeia — reload perderia a cauda do turno persistida); **C** porque abre mid-stream continuaria com `open` velho no storage (reload após abrir-durante-streaming devolveria o drawer fechado apesar da intenção do usuário) e a regra fica assimétrica sem ganho — o write em ambas as direções honra a regra OPS22 existente (open de origem `user` restaura em qualquer viewport; origem `settle` nunca restaura o drawer mobile) e o settle do desktop roda sempre em `ready`, então nenhum write mid-stream nasce da origem `settle`.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`CampaignAISidebarContext.tsx`** (`src/components/campaign/shell/ai/`):
|
||||
- Novo `settledMessagesRef = useRef<UIMessage[]>([])` — o snapshot das mensagens do último write settle-completo; único conteúdo que um write mid-stream pode gravar.
|
||||
- O persist effect atual (linhas 116-121) fica como está (gate `sessionRestored && status === 'ready'`, escreve `messages + open + openBy`) e ganha `settledMessagesRef.current = messages` no corpo, após o gate.
|
||||
- Novo efeito de `open`: gate `sessionRestored && status !== 'ready'` (skips em repouso), deps `[sessionRestored, status, open]`; escreve `writeSollinhaChatSession(settledMessagesRef.current, open, openBy)`.
|
||||
- `openBy` extraído para um closure local `openByForWrite()` compartilhado pelos dois efeitos (mesma regra B188: `userToggledOpenRef || restoredOpenBy === 'user'` → `'user'`, senão `'settle'`).
|
||||
- Comentário do bloco B188 atualizado: mensagens só no settle; `open` é persistido imediatamente (com snapshot), nunca mensagens mid-stream.
|
||||
- **`tests/e2e/campaignSollinhaContext.e2e.spec.ts`**: novo describe `B199` com um teste determinístico (ver Fases).
|
||||
- **Migration:** sem migration. **Access / Consent:** N/A. **UI:** Impeccable B — comportamento existente, sem superfície nova.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Provider** — refatoração do persist effect (snapshot ref + efeito de `open`). `pnpm exec tsc --noEmit` + `pnpm lint`.
|
||||
2. **E2E** — novo teste no `campaignSollinhaContext.e2e.spec.ts`; rodar só o arquivo.
|
||||
- Helper `gatedMockAiChat(page)` ao lado de `mockAiChat` (mesmo wire format SSE): a **primeira** requisição responde imediato; as seguintes ficam em espera até o teste chamar `release()` (contador `requests >= 1` + promise deferida). Registrado no corpo do teste — LIFO sobre o `beforeEach` (mesmo precedente do teste de link externo do B198).
|
||||
- Fluxo do teste (mobile 500px): abre o drawer → manda msg 1 → settle → poll `storedSession.open === true` (o `open: true` velho que o bug deixaria órfão) → manda msg 2 (resposta gated; chat fica busy — testemunha `status !== 'ready'` via mic desabilitado) → clica `Fechar` → drawer some → **poll `storedSession.open === false` ainda com a resposta em voo** (testemunha determinística do fix; sem o fix este poll estoura timeout) → `release()` → poll `storedSession.messages.length === 4` (o settle cobre a conversa toda, com `open: false` — nada foi descartado) → reload → drawer continua fechado → reabre pelo FAB → conversa restaurada visível (msg 1).
|
||||
3. **Gates** — `pnpm gate:fast`; entrega via `pnpm push` (pipeline de execução).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Persistir mensagens mid-stream (o rabbit hole da intenção): proibido pelo corte B188 — o snapshot ref é o único caminho.
|
||||
- Mexer no restore/`openBy` para "consertar" isto: anti-goal explícito — troca um bug raro por outro.
|
||||
- `storage` events / sincronização entre abas, rascunho do input, largura (B166), superfície (B167): regras próprias, intactas.
|
||||
- Unit tests do provider (fiação de efeitos): B188 já estabeleceu o precedente de cobrir o provider via e2e; a lib `sollinhaChatSession.ts` não muda.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
| Risco | Mitigação |
|
||||
| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Write mid-stream grava mensagem pela metade | O efeito novo grava só `settledMessagesRef` (último settle); o write do settle continua o único a persistir mensagens live. |
|
||||
| Ordem dos efeitos cria write velho-sobre-novo | Skip `status === 'ready'` no efeito novo: em repouso só o efeito de settle escreve; mid-stream só o efeito novo escreve (settle effect early-returna antes de tocar o ref). Sem sobreposição. |
|
||||
| OPS22: open de origem `settle` restaura drawer mobile | Impossível mid-stream: o settle roda no mount (status `ready`); todo write mid-stream nasce de `requestOpen`/`toggle` (origem `user`). Regra de restore intacta. |
|
||||
| Drawer fecha com animação; `toHaveCount(0)` flaky | Precedente do B198 no mesmo arquivo usa `toHaveCount(0)`; assert posterior do storage torna o teste independente da animação. |
|
||||
| O teste depende de timing do stream | Não: a resposta da 2ª troca fica gated (fetch pendente) — o close acontece com o chat comprovadamente busy (mic desabilitado), sem corrida. |
|
||||
| StrictMode (dev) duplica efeitos | Writes idempotentes; restore já é ref-guarded (B188); e2e roda com StrictMode real e cobre o conjunto. |
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (close mid-stream persiste `open: false`; conversa persiste no settle; restore/`openBy` intocados)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (sem migration/access/Consent; identificadores em inglês; pt-BR só em strings de UI)
|
||||
- [ ] Testes de domínio previstos: e2e determinístico de fechamento mid-stream + settle cobrindo a conversa (sem unit — lib intocada)
|
||||
|
||||
## Decisões de engenharia (self-score)
|
||||
|
||||
| Decisão | Recomendação | Rejeitadas |
|
||||
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Onde persistir o close mid-stream | Efeito novo de `open` no provider (gate `sessionRestored`, skip `ready`), gravando snapshot de mensagens | Escrever tudo sem gate de status (persiste stream pela metade); fechar no handler do X (lógica no componente, fora do ciclo React do estado) |
|
||||
| Qual conteúdo de mensagens no write mid-stream | `settledMessagesRef` — último snapshot settle-completo | `messages` live (stream em voo); omitir mensagens (quebraria o shape/sessão e um reload perderia tudo) |
|
||||
| Quando o efeito novo escreve | Qualquer mudança de `open` fora de `ready` (abrir e fechar) | Só fechamento (assimétrico; abrir mid-stream deixaria storage velho) |
|
||||
| Teste | E2E com resposta gated (fetch pendente) + poll do storage como testemunha | Delay fixo no stream (flaky); simular estado via storage manual (não cobre o provider) |
|
||||
|
||||
**Self-score decision-quality: 5/5** — aceite inviolado (close mid-stream persiste imediato; conversa intacta no settle; restore intocado); rabbit hole nomeado e cortado pelo snapshot ref; reusa os donos existentes (mesmo efeito de persist, mesma lib, mesmo wire format de mock); a divergência da hipótese da intenção é defensiva e documentada (skip `ready` evita write duplicado sem mudar comportamento).
|
||||
|
|
@ -78,12 +78,22 @@ export const CampaignAISidebarProvider = ({
|
|||
const restoredSessionRef = useRef(false)
|
||||
const sessionReadRef = useRef(false)
|
||||
const restoredOpenByRef = useRef<SollinhaChatSessionOpenOrigin>('settle')
|
||||
// B199: the snapshot of the last settle-complete messages — the ONLY
|
||||
// content an `open` write made mid-stream may persist (writing live
|
||||
// messages would freeze a half-received turn in the session).
|
||||
const settledMessagesRef = useRef<UIMessage[]>([])
|
||||
// B199: the `open` value of the last write by either persist effect — lets
|
||||
// the mid-stream effect skip status transitions that leave `open` untouched.
|
||||
const lastWrittenOpenRef = useRef(open)
|
||||
useEffect(() => {
|
||||
if (sessionReadRef.current || !measured) return
|
||||
sessionReadRef.current = true
|
||||
const session = readSollinhaChatSession()
|
||||
if (session) {
|
||||
setMessages(session.messages)
|
||||
// B199: the mid-stream `open` write must never clobber a restored
|
||||
// conversation with an empty snapshot — seed it from the restore itself.
|
||||
settledMessagesRef.current = session.messages
|
||||
const openBy = session.openBy ?? 'settle'
|
||||
restoredOpenByRef.current = openBy
|
||||
if (session.open && (openBy === 'user' || !isMobile)) setOpen(true)
|
||||
|
|
@ -107,19 +117,35 @@ export const CampaignAISidebarProvider = ({
|
|||
|
||||
// B188: persist the conversation only once the chat has settled — writing
|
||||
// during streaming would freeze a half-received message in the session
|
||||
// (a reload mid-stream loses only that in-flight turn). `open` changes made
|
||||
// mid-stream are picked up by the settle that follows. Empty state is still
|
||||
// (a reload mid-stream loses only that in-flight turn). Empty state is still
|
||||
// written: closing an empty chat must overwrite a stored `open: true`,
|
||||
// otherwise a reload would reopen a drawer the user just closed. The
|
||||
// persisted `openBy` tells the next page whether the open was the user's
|
||||
// (restore it anywhere) or the desktop settle's (desktop-only, OPS22).
|
||||
const openByForWrite = (): SollinhaChatSessionOpenOrigin =>
|
||||
userToggledOpenRef.current || restoredOpenByRef.current === 'user' ? 'user' : 'settle'
|
||||
useEffect(() => {
|
||||
if (!sessionRestored || status !== 'ready') return
|
||||
const openBy =
|
||||
userToggledOpenRef.current || restoredOpenByRef.current === 'user' ? 'user' : 'settle'
|
||||
writeSollinhaChatSession(messages, open, openBy)
|
||||
settledMessagesRef.current = messages
|
||||
lastWrittenOpenRef.current = open
|
||||
writeSollinhaChatSession(messages, open, openByForWrite())
|
||||
}, [sessionRestored, status, messages, open])
|
||||
|
||||
// B199: persist `open` without waiting for the settle — closing the drawer
|
||||
// mid-stream must reach the storage immediately, or a reload would restore
|
||||
// the stale `open: true` and resurrect the drawer. Messages stay at the last
|
||||
// settle-complete snapshot until the next settle (in the abort path the
|
||||
// snapshot IS the terminal content — the documented B188 cut). Skipping
|
||||
// `ready` avoids a duplicate write of the settle effect above, and the
|
||||
// `lastWrittenOpenRef` guard skips plain status transitions that leave
|
||||
// `open` untouched. Deliberately no `messages` dep: never persist a live
|
||||
// stream, only the open flag over the snapshot.
|
||||
useEffect(() => {
|
||||
if (!sessionRestored || status === 'ready' || lastWrittenOpenRef.current === open) return
|
||||
lastWrittenOpenRef.current = open
|
||||
writeSollinhaChatSession(settledMessagesRef.current, open, openByForWrite())
|
||||
}, [sessionRestored, status, open])
|
||||
|
||||
// The chat has two surfaces keyed by viewport — the desktop panel and the
|
||||
// mobile drawer — and both derive from the SAME `open` flag. Crossing the
|
||||
// breakpoint therefore needs no migration of its own: the surface that renders
|
||||
|
|
|
|||
|
|
@ -7,30 +7,72 @@ 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/).'
|
||||
|
||||
/**
|
||||
* The SSE body the mocks reply with. Chunks follow the SDK v7 UI-message-stream
|
||||
* wire format (start / text-start / text-delta / text-end / finish) — a bare
|
||||
* legacy `{"type":"text"}` chunk fails the client schema and the chat would
|
||||
* never settle.
|
||||
*/
|
||||
const streamBody = (reply: string) =>
|
||||
[
|
||||
'data: {"type":"start"}\n\n',
|
||||
'data: {"type":"text-start","id":"t1"}\n\n',
|
||||
`data: {"type":"text-delta","id":"t1","delta":${JSON.stringify(reply)}}\n\n`,
|
||||
'data: {"type":"text-end","id":"t1"}\n\n',
|
||||
'data: {"type":"finish","finishReason":"stop"}\n\n',
|
||||
].join('')
|
||||
|
||||
/**
|
||||
* Mock the AI endpoint with a minimal SSE stream so sending a message neither
|
||||
* needs a DeepSeek API key nor hits the real rate limiter. The chunks follow
|
||||
* the SDK v7 UI-message-stream wire format (start / text-start / text-delta /
|
||||
* text-end / finish) — a bare legacy `{"type":"text"}` chunk fails the client
|
||||
* schema and the chat would never settle. The reply carries a markdown link so
|
||||
* the "navigate without reload" path has a real anchor.
|
||||
* needs a DeepSeek API key nor hits the real rate limiter. The reply carries a
|
||||
* markdown link so the "navigate without reload" path has a real anchor.
|
||||
*/
|
||||
const mockAiChat = (page: Page, reply: string = DEFAULT_REPLY) =>
|
||||
page.route('**/campanha/api/ai-chat', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
body: [
|
||||
'data: {"type":"start"}\n\n',
|
||||
'data: {"type":"text-start","id":"t1"}\n\n',
|
||||
`data: {"type":"text-delta","id":"t1","delta":${JSON.stringify(reply)}}\n\n`,
|
||||
'data: {"type":"text-end","id":"t1"}\n\n',
|
||||
'data: {"type":"finish","finishReason":"stop"}\n\n',
|
||||
].join(''),
|
||||
body: streamBody(reply),
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 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: streamBody(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 })
|
||||
|
|
@ -258,3 +300,70 @@ test.describe('B198 — link de resposta fecha o drawer mobile ao navegar no mes
|
|||
await expect(drawer).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('B199 — fechar o drawer durante streaming persiste fechado no reload', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.slow()
|
||||
await mockAiChat(page)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue