LibreChat/e2e
Danny Avila 155f71f81a
📱 fix: Show Quote Popup for Block Selections and on Touch Devices (#14777)
* 📱 fix: Show Quote Popup for Block Selections and on Touch Devices

The "Add to chat" popup never appeared for two whole classes of selection.

Block-granularity gestures (triple-click, double-click then word-drag) park
the selection's far boundary at the start of the next block. For a message's
closing block that boundary sits outside `.message-render` — on the composer
wrapper or the following message row — while selecting no text there, so the
anchor/focus equality check suppressed the popup. Triple-clicking any earlier
paragraph worked, which is what made this look like an edge case. The range is
now clamped to the message before the check, and selections that really do
carry visible text from another message are still refused.

Touch platforms could not reach the feature at all. A long-press, and every
drag of the native selection handles, emits no mouse event whatsoever — only
`selectionchange` — while the popup was shown exclusively from mouseup,
dblclick and keyup. Showing now also hangs off a settle-debounced
`selectionchange`, gated so an in-progress mouse drag still cannot flicker it.
Accepting was broken independently: the tap is also the gesture that dismisses
the selection, unmounting the button before `click` could land, so touch
commits on `pointerdown` instead. The desktop mousedown path is deliberately
unchanged, since preventDefault on `pointerdown` can suppress the
compatibility mousedown that click depends on.

Two UX consequences of the same code: scrolling re-anchors the popup rather
than dismissing it on the first event (the chat auto-scrolls constantly while
streaming, and a mobile URL bar collapsing fires resize), and touch selections
place the button below the text, clear of the OS Copy/Share callout, with a
44px tap target.

Covered by six e2e tests — three desktop, three on an emulated Pixel 5 with a
real touchscreen — each verified to fail against the pre-fix build.

* 🩹 fix: Address Review Findings and Repair the Scroll Specs

The two failing e2e shards were a defect in the specs, not the component.
`scrollMessages` reached for `.scrollbar-gutter-stable` with a document-wide
query, but the nav and side panels carry that class too, so it could grab a
sidebar list that never scrolls — 0px moved, and only in CI, where the nav
renders differently. The scroller is now reached from the message itself, the
way `MessageNav` does it. The specs also centre the selection first and nudge
by a quarter of the visible height, so the gesture cannot scroll the selection
clean out of view and then blame the popup for going with it.

Review findings, all in `QuoteButton`:

Visibility was tested against the window, but the list scrolls inside a bounded
container, so text can sit clipped under the header or the composer while its
un-clipped rect is still inside the window — leaving the popup floating over
unrelated UI. It is now clipped to the nearest scrollable ancestor.

Touch committed on the press, so starting a scroll on the button, or touching
it and thinking better of it, still added the quote. The excerpt is captured on
the press and committed on the release, and only when that release lands on the
button, restoring the cancellation every button is expected to have. Commit on
press existed because the tap dismisses the selection before `click` fires;
capturing the text up front keeps that safe, and an in-flight press is no
longer allowed to unmount its own target.

A visible popup also described the previous selection for up to the settle
window, so a tap while dragging a native selection handle queued the stale
excerpt. It is dropped as soon as a differing selection starts settling.

Finally, `viaTouch` survived from the last press into keyboard-driven
selections on hybrid devices, which could flip the popup into the touch layout;
keydown clears it.

The cancel path is covered by a new touch spec, verified to fail against a
commit-on-press build.

* 🧵 fix: Reconcile Cancelled Presses, Widen Clipping, Steady the Scroll Specs

Second review round, with one finding taken on trust and flagged rather than
claimed as proven.

A cancelled touch press could leave the popup backed by a selection that no
longer existed. A press deliberately keeps the button alive through a
collapsing selection so the release has a target to be judged against, but a
cancel then dropped the press without ever honouring the collapse it had
masked, so a later tap could add a dead excerpt. Ending a press without
committing now rechecks the live selection and dismisses if it went away.

Visibility now intersects every clipping ancestor of the message rather than
stopping at the nearest. This one is precautionary, not a proven fix: the
review that prompted it describes scroll containers *inside* a message (a wide
table, a code block) shadowing the outer chat scroller, but the walk starts
from the message element, so those are descendants and were never in the chain.
Behaviour is unchanged in the current layout — a spec covering a table-cell
selection passes identically with and without it — and it is kept only because
intersecting the whole chain stays correct if the list is ever nested inside a
further-clipped panel. The comment says exactly this.

The scroll specs were the real instability. They now move the selection between
two positions that are both on screen instead of nudging by a pixel count:
blind nudges kept pushing it under the composer, where the popup correctly
hides, and the chat's own auto-scroll made the landing spot unpredictable. They
also target the opening paragraph, since the closing one is the last content in
the conversation and cannot be carried upward from a list already at maximum
scroll.

The reply fixture gained a table so a selection inside a nested scroll container
is exercised, and a spec covers the cancelled press.

15/15 pass locally.

* 🪟 fix: Judge Quote-Popup Visibility From the Selection, on Both Axes

Third review round. All three findings held up, and each now has a spec that
fails without its fix.

Clipping is now measured from the selection rather than from the message, and
on both axes. A wide table or a long code line scrolls inside its own container
— and `overflow-x: auto` makes the computed `overflow-y` auto, so it clips
vertically too — which means scrolling it sideways carries the selected text out
of view while the message never moves. Walking up from the message could not see
those containers at all, and a vertical-only test could not see that motion.
This supersedes the previous round's precautionary widening, which was kept
without evidence; the evidence is now a spec that scrolls a table past its own
selection.

Publishing a settled selection also checks visibility. Nothing is tracked during
the 300ms settle interval, so a scroll inside that window never reached the
re-anchoring path, and the reading was published off-screen and then clamped
into view — stranding the popup over unrelated UI.

The cancelled-press spec now reproduces the ordering it describes. Collapsing
the selection and cancelling in one synchronous block let the asynchronous
`selectionchange` arrive after the press had ended, which is the ordinary path
and passes either way; it now waits for delivery in between, so the collapse
lands while the press is still masking it. Two other specs needed the same
scrutiny: `toBeHidden` is satisfied by an element that does not exist yet, so
the settle spec sits out the interval before asserting, and it scrolls just past
the container edge rather than to the end of the conversation, because a violent
scroll re-renders the messages and drops the selection for unrelated reasons.

The reply fixture's table is now wide enough to overflow sideways.

17/17 pass, and each new spec was re-run against a build with its own fix
reverted to confirm it fails there.
2026-08-13 00:36:45 -04:00
..
benchmarks perf: Reduce Agent Chat Startup Latency (#14423) 2026-07-25 07:58:20 -04:00
benchmarks-reasoning 🧪 test: Reasoning-Stream Render Perf Benchmark via react-scan (#14494) 2026-07-28 22:18:24 -04:00
bombadil 🧪 test(e2e): add Bombadil property exploration (#14462) 2026-08-10 02:11:06 +02:00
config 🧭 fix: Re-Anchor Parent Activity Phase Bounds (#14741) 2026-08-11 10:16:57 -04:00
fixtures/deployment-skills/e2e-deployment-skill 🗂️ feat: Add Deployment Skill Directory (#13523) 2026-06-05 10:24:28 -04:00
recordings 🎭 feat: Add Credential-Free Playwright Smoke Suite with a Local Mock LLM (#13472) 2026-06-02 16:36:39 -04:00
setup 📱 fix: Show Quote Popup for Block Selections and on Touch Devices (#14777) 2026-08-13 00:36:45 -04:00
specs 📱 fix: Show Quote Popup for Block Selections and on Touch Devices (#14777) 2026-08-13 00:36:45 -04:00
config.local.example.ts 🤲 feat(a11y): Initial a11y improvements, added linters, tests; fix: close sidebars in mobile view (#3536) 2024-08-04 20:39:52 -04:00
jestSetup.js v0.8.7 (#13907) 2026-06-24 14:49:32 -04:00
playwright.config.a11y.ts ⚖️ feat: Add Violation Scores (#8304) 2025-07-07 17:08:40 -04:00
playwright.config.benchmark.ts perf: Reduce Agent Chat Startup Latency (#14423) 2026-07-25 07:58:20 -04:00
playwright.config.bombadil.ts 🧪 test(e2e): add Bombadil property exploration (#14462) 2026-08-10 02:11:06 +02:00
playwright.config.local.ts 🛟 test: Restore Playwright Smoke E2E (#13020) 2026-05-14 09:49:26 -04:00
playwright.config.mermaid.ts 🧜 feat: Open Mermaid Diagrams as Artifacts with SVG/PNG Export (#14713) 2026-08-09 09:02:42 -04:00
playwright.config.mock.ts 📡 fix: Refresh MCP Tools After List-Changed Notifications (#14686) 2026-08-08 13:50:21 -04:00
playwright.config.real.ts 🎯 feat: Tool Intent Label Capability (tool_intents) (#14499) 2026-07-29 15:40:52 -04:00
playwright.config.reasoning-perf.ts 🧪 test: Reasoning-Stream Render Perf Benchmark via react-scan (#14494) 2026-07-28 22:18:24 -04:00
playwright.config.ts 🎭 feat: Add Credential-Free Playwright Smoke Suite with a Local Mock LLM (#13472) 2026-06-02 16:36:39 -04:00
README.md 🧪 test(e2e): add Bombadil property exploration (#14462) 2026-08-10 02:11:06 +02:00
types.ts 🤲 feat(a11y): Initial a11y improvements, added linters, tests; fix: close sidebars in mobile view (#3536) 2024-08-04 20:39:52 -04:00

LibreChat e2e

The mock e2e profile is the safest default for generated tests. It starts LibreChat with e2e/config/librechat.e2e.yaml, injects an in-process fake LLM (via LIBRECHAT_TEST_RUN_HOOK), creates an authenticated e2e user, and avoids real provider credentials.

Stream Stores and Shards

The mock profile uses the in-memory generation stream store by default. To exercise the same browser scenarios through a real Redis job store and pub/sub transport, start Redis on port 6379 and run:

npm run e2e:mock:redis

Memory mode explicitly disables Redis. Redis mode defaults to database 15 with a LibreChatE2E key prefix, and fails closed: the test server pings Redis and verifies that the generation job manager did not silently fall back to memory. Override REDIS_URI or E2E_REDIS_KEY_PREFIX when needed.

CI runs the complete mock suite in both stream modes. Each mode is split across four Playwright shards, while each shard keeps one worker so tests do not contend for the shard's authenticated user and database:

npx playwright test --config=e2e/playwright.config.mock.ts --shard=1/4

Property-based browser testing

Bombadil explores randomized sequences across the core chat loop, message branches, parallel multi-conversation responses, model changes, reloads, and sidebar conversation lifecycle operations:

npm run e2e:bombadil

Set BOMBADIL_TIME_LIMIT for longer local or scheduled runs. Failures leave a reproducible trace under e2e/.generated/bombadil-output; rerun it with:

BOMBADIL_REPRODUCE=e2e/.generated/bombadil-output npm run e2e:bombadil:run

Reproducing a real violation is expected to fail the Playwright test. Before a new run overwrites the active output, the harness archives it under e2e/.generated/bombadil-history/. Reproduction can diverge when streaming timing changes; Bombadil reports that explicitly.

The harness uses the credential-free mock-LLM profile, so exploration never sends billable provider requests.

CI runs the broad property exploration for five minutes in the non-blocking Bombadil Property Exploration workflow. If a property fails, download the bombadil-reproduction-* artifact into e2e/.generated/bombadil-output/, then reproduce it locally:

BOMBADIL_REPRODUCE=e2e/.generated/bombadil-output npm run e2e:bombadil:run

The accompanying bombadil-diagnostics-* artifact contains the captured CI log, Playwright HTML report, and Playwright test results. A Bombadil failure produces a workflow warning but does not block merge.

The default instruments inline JavaScript only because instrumenting LibreChat's full Vite bundle can exceed Bombadil's driver timeout during stateful runs. Set BOMBADIL_INSTRUMENT_JAVASCRIPT=files,inline for shorter coverage-guided experiments.

The branch reload, fork submission, model/conversation, HITL pause/resume, and mid-run steering lifecycle properties can be run independently:

npm run e2e:bombadil:branch-reload
npm run e2e:bombadil:fork-lifecycle
npm run e2e:bombadil:model-lifecycle
npm run e2e:bombadil:hitl
npm run e2e:bombadil:steering

These focused commands are diagnostic properties: they exit nonzero when they reproduce a product invariant violation. Reproduce a focused trace with its matching :run script and output directory, for example:

BOMBADIL_REPRODUCE=e2e/.generated/bombadil-output-hitl npm run e2e:bombadil:hitl:run

HITL drives a real ask_user_question checkpoint through the answer/resume controller, reloads while the question is paused, answers it once, and reloads the completed conversation. Steering submits an in-flight steer during a slow MCP-backed run, checks that it moves exactly once from the composer anchor into the response at the tool boundary, and reloads the applied state. The model lifecycle property is the passing control. The branch reload and fork properties preserve their minimal failing traces.

Recording Tests

Use Playwright codegen when you want to turn an exploratory browser session into a draft test:

npm run e2e:record

That command builds the app, starts the LibreChat test server (with an in-process fake LLM) when needed, writes e2e/storageState.json, and opens Playwright codegen at /c/new. The npm script uses http://localhost:3333 so it does not collide with a normal dev server on 3080. Raw recordings are written to e2e/recordings/ and ignored by git.

For a real local LibreChat config instead of the mock profile:

npm run e2e:record:local

Useful direct options:

node e2e/setup/record.js --url=http://localhost:3080/c/new
node e2e/setup/record.js --profile=local --no-output
node e2e/setup/record.js --auth-only
node e2e/setup/record.js --output=e2e/recordings/settings-draft.spec.ts

LLM-Assisted Loop

  1. Start npm run e2e:record.
  2. Let the LLM use Computer Use to operate the headed Playwright browser.
  3. Stop codegen after the workflow is captured.
  4. Move the useful parts from e2e/recordings/ into a committed spec under e2e/specs/mock/.
  5. Replace brittle generated selectors with role, label, text, or data-testid locators.
  6. Add assertions that prove the behavior, not just the clicked path.
  7. Run the finished spec with npm run e2e:mock -- <spec name>.

Generated recordings are a draft, not the final test. The committed version should use the shared helpers in e2e/specs/mock/helpers.ts where possible, wait on network or visible UI state instead of fixed sleeps, and keep test data deterministic.