diff --git a/mitra_app/.maestro/flows/ts-mitra-3-08-back_press_after_session_expired_no_red_screen.yaml b/mitra_app/.maestro/flows/ts-mitra-3-08-back_press_after_session_expired_no_red_screen.yaml new file mode 100644 index 0000000..adf5923 --- /dev/null +++ b/mitra_app/.maestro/flows/ts-mitra-3-08-back_press_after_session_expired_no_red_screen.yaml @@ -0,0 +1,131 @@ +# ts-mitra-3-08 — §3 Back-press after session expires returns to Home (regression) +# Spec ref: requirement/flow_mitra.mermaid.md §3 + project-pending-mitra-chat-disconnect-fix +# +# Regression test for the 2026-05-15 fix in +# mitra_app/lib/core/chat/mitra_chat_notifier.dart::disconnect() — the +# synchronous `state = MitraChatInitialData()` used to fire from +# mitra_chat_screen.dart::deactivate() during back-nav, notifying Riverpod +# watchers while the widget tree was mid-teardown → red error screen. +# +# Fix: state reset is now deferred to a post-frame callback via +# SchedulerBinding.addPostFrameCallback(...). If that fix regresses, this +# flow will fail at the "back chevron" step — the chat screen either stays +# put (no nav because the engine restarted) or the Home tab assertions miss +# because Flutter is showing its red error screen. +# +# Walks (extends ts-mitra-3-04): +# 1. Seed + sign in + online + accept blast → /chat/session/:id +# 2. force-session-expires-at (-1s) → backend fires session_expired WS +# 3. Wait for ended-state chrome (proves WS frame landed) +# 4. Tap back chevron on the chat AppBar +# 5. Assert mitra is back on Home (Bestie greeting + tile grid visible) +# 6. Assert no Flutter red-screen text leaked through +appId: com.mybestie.mitra +env: + TEST_PHONE: "+628200000908" + MITRA_DISPLAY_NAME: "Maestro BackNav" + BACKEND_INTERNAL_URL: http://localhost:3001 +--- +- runScript: + file: ../scripts/seed_mitra.js + env: + TEST_PHONE: ${TEST_PHONE} + MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME} + IS_ACTIVE: "true" + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- runScript: + file: ../scripts/reset_phone.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} + +- launchApp: + clearState: true +- extendedWaitUntil: + visible: + text: "(?s).*Halo Mitra Bestie.*" + timeout: 10000 + +- tapOn: + point: "50%, 53%" +- inputText: "8200000908" +- tapOn: "(?s).*kirim kode.*" +- extendedWaitUntil: + visible: + text: "(?s).*masukin 6 digit kode.*" + timeout: 10000 +- runScript: + file: ../scripts/peek_otp.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- inputText: ${output.OTP} + +- extendedWaitUntil: + visible: + text: "(?s).*Kamu lagi (ONLINE|OFFLINE).*" + timeout: 15000 + +- runFlow: + when: + visible: + text: "(?s).*Kamu lagi OFFLINE.*" + commands: + - tapOn: "(?s).*Nyalain Status.*" + - extendedWaitUntil: + visible: + text: "(?s).*Kamu lagi ONLINE.*" + timeout: 10000 + +- waitForAnimationToEnd: + timeout: 3000 + +- runScript: ../scripts/customer_blast_now.js +- extendedWaitUntil: + visible: + text: "(?s).*Curhat Baru!.*" + timeout: 10000 + +- tapOn: "(?s).*Terima.*" + +- extendedWaitUntil: + visible: + text: "(?s).*sesi aktif · Chat.*" + timeout: 15000 + +# Force-expire the active session → ended chrome renders. +- runScript: + file: ../scripts/force_session_expires_at.js + env: + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} + +- extendedWaitUntil: + visible: + text: "(?s).*Durasi sesi habis.*" + timeout: 15000 + +# The regression-trigger moment: tap back while the chat screen is in +# expired state. Pre-fix this raised the Flutter red-screen and the +# engine restarted; the user landed back on /login. Post-fix the back +# chevron pops to Home cleanly. +# +# The chevron is `IconButton(Icons.chevron_left)` with no semantics label, +# so we tap by AppBar leading position. 8% from each edge hits the icon +# center reliably on the standard mitra emulator (5556 → Pixel 6 / 1080w). +- tapOn: + point: "8%, 8%" + +# Post-back assertions: we're on Home (Bestie shell). +- extendedWaitUntil: + visible: + text: "(?s).*Kamu lagi (ONLINE|OFFLINE).*" + timeout: 8000 +- assertVisible: "(?s).*Halo, ${MITRA_DISPLAY_NAME}.*" + +# Sanity: Flutter red-screen text never leaked through. Flutter's default +# debug error widget renders "EXCEPTION CAUGHT BY" and a stack trace; if +# either is visible the disconnect fix has regressed. +- assertNotVisible: "(?s).*EXCEPTION CAUGHT BY.*" +- assertNotVisible: "(?s).*Tried to modify a provider.*" + +- takeScreenshot: ts-mitra-3-08-back-press-no-red-screen diff --git a/mitra_app/CLAUDE.md b/mitra_app/CLAUDE.md index 4f38f3b..abdc3a9 100644 --- a/mitra_app/CLAUDE.md +++ b/mitra_app/CLAUDE.md @@ -46,3 +46,20 @@ void dispose() { ``` A lint rule (`no_ref_in_dispose` in `halo_lints`) fails `dart run custom_lint` on this pattern. When debugging "screen frozen after navigation", grep the *previous* screen's State for `void dispose()` followed by `ref\.` — that's the first suspect. + +### Never mutate notifier `state` synchronously from `deactivate()` cleanup + +`deactivate()` is the safe place to call `ref.read(...).cleanup()`, but if that cleanup does `state = SomeData()`, Riverpod will notify watchers while the widget tree is mid-teardown → `Tried to modify a provider while the widget tree was building.` → red error screen + engine restart (in debug) or silent state loss (in release). Real case: `mitra_chat_notifier.dart::disconnect()` was firing this on back-press from the session-ended chat screen. + +**Rule:** when a notifier's cleanup-from-`deactivate()` path needs to reset its own state, defer the assignment to the next frame: + +```dart +void disconnect() { + _cleanup(); // closing channels / cancelling subs is fine synchronously + SchedulerBinding.instance.addPostFrameCallback((_) { + state = const SomeInitialData(); + }); +} +``` + +The synchronous side-effects (closing the WS, cancelling timers) still happen immediately. Only the `state =` assignment is deferred, which is a no-op for users — they're navigating away anyway. Regression coverage: `.maestro/flows/ts-mitra-3-08-back_press_after_session_expired_no_red_screen.yaml`.