* 📱 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. |
||
|---|---|---|
| .. | ||
| benchmarks | ||
| benchmarks-reasoning | ||
| bombadil | ||
| config | ||
| fixtures/deployment-skills/e2e-deployment-skill | ||
| recordings | ||
| setup | ||
| specs | ||
| config.local.example.ts | ||
| jestSetup.js | ||
| playwright.config.a11y.ts | ||
| playwright.config.benchmark.ts | ||
| playwright.config.bombadil.ts | ||
| playwright.config.local.ts | ||
| playwright.config.mermaid.ts | ||
| playwright.config.mock.ts | ||
| playwright.config.real.ts | ||
| playwright.config.reasoning-perf.ts | ||
| playwright.config.ts | ||
| README.md | ||
| types.ts | ||
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
- Start
npm run e2e:record. - Let the LLM use Computer Use to operate the headed Playwright browser.
- Stop codegen after the workflow is captured.
- Move the useful parts from
e2e/recordings/into a committed spec undere2e/specs/mock/. - Replace brittle generated selectors with role, label, text, or
data-testidlocators. - Add assertions that prove the behavior, not just the clicked path.
- 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.