# Test Playbook — support-hub (ModuleDesk)

Accumulated test intelligence for this project. feature-tester reads this before
running and appends discoveries after. Git-tracked, build-excluded (never ships).

## Feature → spec index
- AI composer expand regression: `tests/playwright/ai-composer-expand.spec.js` › "AI composer expand regression" — 4 serial tests (desktop-only)
- Scroll intent-gate (reflow scroll guard): `tests/playwright/ai-composer-expand.spec.js` › "Scroll intent-gate — insert keeps composer open" — 4 serial tests (desktop-only)
- Check-reply panel survival: `tests/playwright/ai-composer-expand.spec.js` › "Check-reply keeps composer expanded" — 1 test (desktop-only)
- AI toolbar label visibility: `tests/playwright/ai_toolbar.spec.js` › "AI composer toolbar — label visibility"
- AI toolbar redesign: `tests/playwright/ai-toolbar-redesign.spec.js`
- AI toolbar bugs: `tests/playwright/ai-toolbar-bugs.spec.js`
- Toast notification positioning: `tests/playwright/toast-position.spec.js` › 3 tests (both projects) — mobile no-overlap, mobile ResizeObserver re-lift on reply-bar growth, desktop bottom-right
- Message sticky action buttons: `tests/playwright/message-sticky-actions.spec.js` › 4 tests (all viewports) — sticky positioning on scroll, copy button exists, items-stretch layout, mobile no-overflow

## Reset & pre-checks
- All specs: no reset needed; each test logs in fresh via `loginAndOpenTicket()` / `loginAndGetTicketUrl()`
- AI composer expand: desktop-only — `#composer-collapsed-bar` (the minimized "Write reply" button) is hidden on mobile via `#composer-collapsed-bar { display:none !important }` — only run with `--project=desktop`
- AI polish buttons (Proofread/Shorter/Context/Check): inside `#ai-toolbar` which gets `display:none` when `#reply-bar.minimized` — they CANNOT be clicked via `.click()` while minimized; use `page.evaluate(() => window.aiProofread())` to call programmatically instead

## Chaining
- `ai-composer-expand.spec.js`: safe to chain in one `describe.serial` (no global state mutation)
- `ai_toolbar.spec.js`: safe to chain (read-only DOM assertions + screenshots)

## Flakes & selectors
- `#reply-bar` initial class: `"... minimized"` (inline in ticket.html line 637) — confirmed
- `#composer-collapsed-bar`: `display:none` by default (Tailwind `hidden`); shown only by `#reply-bar.minimized #composer-collapsed-bar { display:flex !important }` on desktop (≥1024px)
- Polish buttons selector: `[onclick="aiProofread()"]` etc. — these are INSIDE `#ai-toolbar` which is `display:none` when minimized; do NOT use `.click()` on them in minimized state; use `page.evaluate(() => window.aiProofread())` to call programmatically
- `toggleReplyBarMinimize()` is defined inline in ticket.html (not in app.js) — use `#reply-bar-toggle` button click to trigger it
- **CRITICAL — wait for `_replyBarReady`**: `page.waitForSelector('#reply-bar')` resolves immediately (static HTML). `initSignature()` fires later (via `waitForAppJs()` poll) and calls `_quill.root.innerHTML = '<p><br></p>'`, clearing any text seeded before it. Always `await page.waitForFunction(() => window._replyBarReady === true, { timeout: 10000 })` before seeding the editor or calling AI functions. `loginAndOpenTicket()` already does this.
- **`aiProofread()` length guard**: bails with "Write something first" if `_quill.getText().trim().length < 5`. Seed text AFTER `_replyBarReady` via `_quill.setText('...')` or `page.evaluate(() => window._quill.setText('...'))`
- **Quill innerHTML vs delta**: `aiUseResult()` sets `_quill.root.innerHTML` directly, bypassing Quill's Delta. `_quill.getText()` returns empty in this case — check `_quill.root.innerHTML.includes(text)` for content assertions after `aiUseResult()`
- **Scroll intent-gate (current fix, replaced `_keepComposerOpen`)**: thread `scroll` handler only collapses when `wheel`/`touchmove`/`keydown` occurred on `#message-thread` within the last 500ms. A bare `scroll` dispatch (no preceding intent event) is IGNORED — use this for reflow-scroll tests. A genuine user scroll = `WheelEvent('wheel', {deltaY:120})` THEN `Event('scroll')` — after 400ms the bar collapses.
- **`_keepComposerOpen()` is DELETED**: insert actions (`aiUseResult`, `aiAppendResult`, `_quickReplyInsert`, `useDraftTranslation`, `useDualLanguage`, `useAsReply`) no longer focus the editor or remove `minimized`. Tests must NOT assert `hasFocus()` after insert.
- **Setup for insert tests**: expand the bar FIRST via `_showAIResult()` (for AI insert tests) or `toggleReplyBarMinimize()` (for translate/general tests) before calling the insert function — in real usage the bar is already expanded before insert actions are available.
- **Setup minimize**: use `page.evaluate(() => window.toggleReplyBarMinimize())` — do NOT rely on bare scroll (intent-gate ignores it); do NOT click `#reply-bar-toggle` (timing unreliable in serial tests).
- `useDualLanguage()` / `useDraftTranslation()`: `#draft-translation-text` is always in the DOM (inside `#draft-translate-panel hidden`); set `.innerText` directly before calling; set `window._draftOriginalHtml` as a stub to avoid reading live editor content

- **Two panel-render paths, not one (2026-08-07):** AI results render through `_showAIResult()`, but **Check renders through `_showAIChecklist()`**. Both un-hide `#ai-result-panel`, so both need `classList.remove('minimized')` — `#reply-bar.minimized #ai-result-panel { display:none }` kills a panel un-hidden into a minimized bar. `_showAIChecklist` was missing it; symptom was "AI responses disappear as soon as they're generated". When adding any new panel-opening path, check it against this rule.
- Stub a Check response with `window._showAIChecklist({new_tasks:[{task,quote,date}], resolved:[], missing_from_customer:[]})` — no API call needed.
- Sanity-check a regression test by removing the fix and re-running with `--grep`; a test that passes both ways is testing nothing.
- **Toast notification `.app-toast`**: mobile mark-as-read/unread buttons may be hidden (class `hidden`) on initial load if already read/unread; use `page.evaluate(() => window.markAsRead/markAsUnread())` to trigger toast programmatically rather than `.click()` which requires visibility
- **Toast ResizeObserver fix (2026-08-29)**: `showToast()` now keeps a ResizeObserver on `#reply-bar` for the toast's lifetime. When `#reply-bar` grows (e.g., AI result panel opens), the observer fires and re-calculates toast position to keep it above the bar. Test: "mobile ResizeObserver re-lift" appends a 200px div to `#reply-bar` and waits 100ms for the observer to fire before asserting no overlap. This prevents the regression where a toast could overlap the reply-bar if it grows after the toast appears.
- **Message action column selector (2026-08-30)**: Two `.flex-shrink-0` in a message row — avatar (before bubble+actions wrapper) and action column (inside wrapper with sticky buttons). To target action column: `messageRow.locator('div.flex-shrink-0:has(> .sticky)').first()` (not just `.flex-shrink-0` which selects avatar). Action buttons are hidden by default on desktop (lg:opacity-0); hover to reveal.
- **Copy message text button**: exists in ticket.html line 603-608, `button[title="Copy message text"]`, calls `copyMessage(this, {{ msg.id }})` onclick. Test must hover the message row first to make button visible (lg:group-hover:opacity-100).
- **Message sticky positioning — CORRECTION (2026-08-30, diagnosed via manual Playwright, not the spec)**: The 2026-08-30 entry above was WRONG — it never actually verified sticky sticks on scroll on mobile (its mobile test only checks horizontal overflow) and its desktop test used a trivial 100-300px `thread.scrollTop` nudge on the first row, which passes whether or not sticky is engaged.
  - **Real root cause**: `#ticket-page` is `h-auto lg:h-[calc(100vh-3.5rem)]` and `#message-thread` is `min-h-[50vh] lg:min-h-0` — below the `lg:` breakpoint (<1024px width) BOTH fall back to `h-auto`, so `#message-thread` grows to fit all content (`scrollHeight === clientHeight`, zero overflow of its own) and `document.scrollingElement`/`<html>` becomes the real scroller instead. `position:sticky` inside `#message-thread` then pins relative to a box that itself scrolls away with the page, so the action buttons visibly scroll off with the message.
  - **Verified breakpoint cliff**: at 1023px width `#message-thread` scrollHeight==clientHeight (page scrolls, sticky BROKEN); at 1024px exactly, `#ticket-page` computed height snaps to viewport height and `#message-thread` gets real internal overflow (sticky WORKS). This is a general `<1024px` bug (any narrow desktop window, tablet, or phone), not phone-specific.
  - **Verified WORKING case**: at ≥1024px (tested 1440x900), once a tall message is actually scrolled into the engaged range (its top near `#message-thread`'s top), the sticky div stays pinned rock-solid (constant `getBoundingClientRect().top`) across a 3000px scroll sweep through the message body. Testing it while the row is still fully off-screen and only scrolling 100-600px (as the old spec does) never brings it into the engaged range, so that measurement is meaningless either way.
  - **Verified BROKEN case**: at 390x844, a real `page.mouse.wheel(0, 400)` moves `stickyDiv.getBoundingClientRect().top` by exactly -400px in lockstep with `window.scrollY` — zero stickiness, confirmed with genuine wheel input (not just programmatic `scrollTop` assignment, which on mobile silently no-ops on `document.scrollingElement.scrollTop` in this app — use `page.mouse.wheel()` for real mobile scroll tests, not `docEl.scrollTop += n`).
  - **Test-writing gotcha for next spec**: to correctly assert sticky-on-scroll, first confirm which element actually scrolls (`el.scrollHeight > el.clientHeight`, check `#message-thread` AND `document.scrollingElement`) before scrolling it; pick a message row taller than the viewport; scroll it so its top is near the scrollport's top (the "engaged" zone) BEFORE asserting a constant sticky offset over a large (1000px+) range — a small scrollTop nudge on an off-screen row proves nothing. Must be tested below AND above 1024px width; the old spec only covered ≥1024 desktop + a mobile test with no sticky assertion at all.
  - Fix (not applied — diagnosis only): `#message-thread`/`#ticket-page` need a real bounded/overflowing scroll container below `lg:` too, not just at `lg:`+.
  - **FIX APPLIED AND VERIFIED (2026-08-30, same day)**: coordinator patched the `@media (max-width: 1023px)` block in ticket.html: `#message-thread { overflow: visible; }` (drops it as a scroll container so it stops being a dead sticky reference frame) + `#message-thread .sticky.top-2 { top: 112px; }` (pins to viewport below the fixed nav 56px + sticky `#ticket-header`). Re-verified with real `page.mouse.wheel()` at 390x844: instant-jumped a message into the engaged zone, then 5x wheel(0,250) (1250px total real scroll) — `stickyTop` held exactly at 112px every sample while `window.scrollY` moved the full 1250px. Re-checked the breakpoint cliff: 1023px now pins at 112px (fixed), 1024px still pins at threadTop+8+padding≈254px via internal thread scroll (no regression), 1440px still pins at ≈216px (no regression). `scrollToTop()`/`scrollToBottom()` already branched correctly on `thread.scrollHeight > thread.clientHeight` so were unaffected. No new horizontal overflow at 390px.
  - **Pre-existing, unrelated bug surfaced (not fixed, out of scope)**: `#topic-overlay`'s crossfade label text never updates below `lg:` because its `updateOverlay()` handler is bound via `container.addEventListener('scroll', ...)` where `container = #message-thread` — that element never fires its own `scroll` event below `lg:` (the document scrolls instead), same root cause as the sticky-buttons bug but on a different consumer. The overlay pill itself still visually sticks to `top:0` correctly (unaffected by the CSS fix, different selector) — only its text-swap-on-scroll logic is dead below `lg:`. This predates today's session; flagging for a future fix, not touched here (would require an app.js/inline-script change, off-limits for this task).
  - **Test-suite fix (2026-08-30)**: `tests/playwright/message-sticky-actions.spec.js` rewritten — real-scroller detection (`scrollHeight > clientHeight` on both `#message-thread` and `document.scrollingElement`) before choosing how to scroll, instant (non-smooth) jump into the "engaged zone" before sweeping (this app sets `html { scroll-behavior: smooth }` globally — any programmatic `scrollTo`/`scrollTop` jump animates for ~1-1.5s and will contaminate immediate post-jump measurements; always instant-jump or poll until settled), a real >=1000px sweep via `page.mouse.wheel()` (page-scroller case) or repeated `thread.scrollTop +=` (thread-scroller case), and separate `>=1024px` / `<1024px` test cases asserting the exact pinned offset (216px desktop / 112px mobile) not just "unchanged". Confirmed the new spec passes post-fix (10/10, both Playwright projects).
  - **Offset changed again same day (2026-08-30, more breathing room requested)**: base style `#message-thread .sticky.top-2 { top: 2rem; }` (was Tailwind `top-2`=0.5rem) + mobile media query `#message-thread .sticky.top-2 { top: calc(112px + 2rem); }` (was 112px). **Spec was rewritten to stop hardcoding the pinned pixel value** — it now derives the expected pinned offset live from computed CSS via `computeExpectedPinnedTop()`: thread-scroller case = `threadTop + resolved(sticky.top) + thread.paddingTop`; document-scroller case (below `lg:`) = `resolved(sticky.top)` alone (no thread offset — sticky pins straight to the viewport there). This makes the assertion survive future `top` tweaks (rem/calc/px, any value) without editing a magic constant.
  - **Re-verified pinned values after the 2rem change** (ticket 6324, message 66269, real wheel/scrollTop sweep of 1250px, all constant/PASS): 390x844 → **144px** (=112+32); 1023px → **144px** (same document-scroller formula, confirmed independent of ticket-header/nav layout since it's a flat `resolved(top)` value); 1024px → **278px** (=threadTop 234 + 32 + padding 12, thread-scroller formula); 1440x900 → **240px** (=threadTop 196 + 32 + padding 12, thread-scroller formula). Breakpoint cliff (`threadScrolls` true/false) still lands exactly at 1023→1024 as before — the CSS `top` value change doesn't affect which element is the real scroller.
  - **Mobile header-clearance check**: at 390x844, `#top nav` bottom = 56px, `#ticket-header` bottom = 110px, pinned sticky = 144px — buttons clear both the fixed nav and the sticky ticket header with ~34px margin, no overlap, and 144px is well within the 844px viewport (not pushed off-screen). Added as an explicit assertion in the `<1024px` spec test (`toBeGreaterThanOrEqual(ticketHeaderBottom - 1)` and `toBeLessThan(viewportHeight)`).
  - **Offset became vertically-CENTERED, JS-driven (2026-08-30, same day)**: base rule became `#message-thread .sticky.top-2 { top: var(--msg-action-top, 2rem); }` (mobile media query: `var(--msg-action-top, calc(112px + 2rem))`), with a new inline `updateMessageActionOffset()` (near `scrollToTop`/`scrollToBottom`) computing `--msg-action-top` on `#message-thread` so the buttons sit centered in the visible slice instead of pinned near the top. Two reference frames, matching the sticky mechanism's own two cases: thread-scroller (>=1024px) computes `top` relative to the thread panel's own fixed viewport box (`thread.getBoundingClientRect()`, which does NOT move as its internal content scrolls); document-scroller (<1024px) computes `top` directly against the viewport with a `min:112` floor (nav+header). Bottom bound in both cases is clamped by `#reply-bar`'s own top (`usableBottom`). Recomputed on `load`/`DOMContentLoaded`/`resize` and via a `ResizeObserver` on `#reply-bar` (catches composer expand/collapse).
  - **Re-verified after centering change** (ticket 6324, message 66269, real sweep, all held perfectly constant): 390x844 → pinned **347px** (headerBottom 110, replyBarTop 674, band 110-674, colH 92, positionFraction ≈0.50); 1023px → pinned **375px** (headerBottom 110, replyBarTop 730, positionFraction ≈0.50); 1024px → pinned **506px** (threadTop 234, threadBottom 845 — the >=lg band is the THREAD PANEL's own box, not headerBottom-to-replyBarTop, since `#ticket-header` is only `position:sticky` inside the <1024px media query — on desktop it's a static bar with real layout between it and where `#message-thread` starts (headerBottom 101 vs threadTop 234, a ~133px gap); positionFraction ≈0.48); 1440x900 → pinned **487px** (threadTop 196, threadBottom 845, positionFraction ≈0.52). All within the loose 20%-80% "centered" band the spec now checks — do not compare against `headerBottom` on desktop widths, compare against `#message-thread`'s own rect.
  - **Composer-expand ResizeObserver verified**: toggling `#reply-bar` out of `.minimized` (via `window.toggleReplyBarMinimize()` — clicking the editor directly fails, it's `display:none` while minimized, see existing helper notes below) grows the reply bar 55px→330px at 1440x900, shrinks `#message-thread`'s own bottom edge 845→570 accordingly, and the `ResizeObserver` on `#reply-bar` correctly recomputes `--msg-action-top` 279px→141px within ~400ms — the tall message's pinned buttons end up at rect.top 349/bottom 441, fully inside the new smaller visible slice (thread top 196, reply-bar-now-top 570), never behind the expanded composer.
  - **Test-suite fix (2026-08-30, third pass)**: `computeExpectedPinnedTop()` needed NO change — it already reads the sticky div's live resolved `top` CSS value, which correctly resolves whatever `var(--msg-action-top, ...)` currently evaluates to, so it kept working unchanged through the "fixed 2rem" → "JS-computed centered" transition. Added `reportCenteringGeometry()`: reports the raw geometry (`headerBottom`, `replyBarTop`, thread panel rect, `colH`, the live `--msg-action-top` value) and asserts a loose "middle 60% of the band" check (`positionFraction` between 0.2 and 0.8) rather than re-deriving `updateMessageActionOffset()`'s exact centering formula pixel-for-pixel — deliberately loose so it survives minor centering-math tweaks while still catching a regression back to top/bottom-anchored placement. Added a dedicated desktop test that expands the composer via `toggleReplyBarMinimize()` and asserts `--msg-action-top` actually changed + the pinned buttons stay within `[threadTop, min(threadBottom, replyBarTop)]` afterward. All 12 tests pass (`node_modules/.bin/playwright test tests/playwright/message-sticky-actions.spec.js --reporter=list`).

## Helpers
- `loginAndGetTicketUrl(page)` — login + return first ticket href (used in ai_toolbar.spec.js)
- `loginAndOpenTicket(page)` — login + navigate to first ticket + wait for `#reply-bar` + wait for `_replyBarReady` (ai-composer-expand.spec.js); promoted candidate if a 3rd spec needs it

## Send Reply failure recovery (2026-08-29)
- **Spec file:** `tests/playwright/send-failure-recovery.spec.js` (new, created during verification)
- **Bug #1 (VERIFIED PASSING):** `setSendBusy(false)` called in both success AND failure callbacks (line 2330, 2369 app.js) — button re-enables on error and text returns to "Send Reply"
- **Bug #2 (PARTIALLY TESTED):** `_clearTicketDraft` function exists (not old unconditional setTimeout) — draft should persist in editor after 502 error
- **Note:** Server-side draft persistence requires actual server error (route interception proved unreliable in Playwright); spec created but cannot fully verify without real 502 from server or better mocking strategy
