LibreChat/e2e/specs/mock/quotes.spec.ts
Danny Avila e1ac7d2bda
ci: Settle the E2E Reply Before the Double-Click Quote (#14840)
* test(e2e): grant MULTI_CONVO.USE in the mock e2e config

`agent-skills-added.spec.ts` drives the composer's `+` command, which opens the
added-model popover. That path is gated on MULTI_CONVO.USE:

    if (!hasMultiConvoAccess || !plusCommandEnabled || isAssistantsEndpoint(endpoint)) return;

The mock config never sets `interface.multiConvo`, so the permission falls
through to the seeded role default and `handlePlusCommand` returns before
opening the popover. The spec then fails on a popover that is absent from the
DOM entirely, which reads as a selector or timing problem rather than a missing
permission.

Set it explicitly, the same way `contextCost` is set just above for the usage
gauge — the mock config's job is to make each exercised feature's gate explicit
rather than inherit a default.

* test(e2e): wait for the reply to settle before the double-click quote

`quotes.spec.ts` › 'summons the popup from a native double-click word
selection' double-clicks a word as soon as `mockReply` becomes visible. But
`sendMessage` resolves on the stream *response*, not on the final render, so the
reply can still be re-rendering.

A streaming markdown re-render swaps out the text node the selection points at,
which collapses the selection — the same mechanism the sibling
`selectionchange` test documents deliberately. A double-click landing mid-stream
therefore loses its selection before the popup can be clicked, and because the
whole gesture is wrapped in `toPass`, every retry re-runs into the same
still-streaming reply rather than recovering from a one-off.

This is a different race from the one #14777 fixed. That one is the *selection*
still settling (touch long-press, native handle drags, block-granularity
gestures) and is handled inside QuoteButton. This one is the *reply* still
streaming, which no amount of component-side settling can absorb.

Observed on a downstream fork running this suite on slower hardware: the test
fails all three attempts, deterministically on the in-memory stream store while
the Redis lane passes the same shard — the in-memory store's final re-renders
land late enough to outlive the gesture. Four separate runs, same split.

Wait for the reply text to hold steady before selecting.

* ci(e2e): install ffmpeg so first-retry video actually records (#14841)

`playwright.config.mock.ts` sets `video: 'on-first-retry'`, but the runner only
installs `install-deps chrome`, which does not include ffmpeg. Without it the
first retry fails inside `browserContext.newPage` while setting up video
recording — before the test body runs.

The cost is the retry itself: a genuinely flaky test loses the attempt that
would have recovered it, and the reported failure is a video-setup error rather
than the original symptom.

Bounded and non-fatal on purpose. The CLI has been observed hanging after the
download completes on these runners, so the step is wrapped in `timeout` and
its failure is swallowed — if ffmpeg cannot be installed the job proceeds
exactly as it does today, and no lane is blocked on it.

Applied to both jobs that run Playwright (`e2e_shards` and
`mcp_tool_list_changed`), since both configure retries.
2026-08-15 10:48:11 -04:00

876 lines
37 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test, devices } from '@playwright/test';
import type { Page } from '@playwright/test';
import {
MOCK_ENDPOINTS,
MOCK_REPLY_TEXT,
NEW_CHAT_PATH,
messagesView,
mockReply,
selectMockEndpoint,
sendMessage,
} from './helpers';
/**
* Place a real DOM Selection over `needle` inside the most recent
* `.message-render` that contains it, then dispatch `mouseup` so the
* `QuoteButton` listener fires — the deterministic equivalent of a user
* drag-selecting that text to summon the "Add to chat" popup.
*
* Pass `emitMouseUp: false` to model a touch selection instead: phones deliver
* a long-press (and every native handle drag) as a bare `selectionchange` with
* no mouse event anywhere in the sequence, which is the whole reason the popup
* needs a mouse-less path.
*/
async function selectMessageText(page: Page, needle: string, emitMouseUp = true) {
await page.evaluate(
({ text, emitMouseUp: withMouse }) => {
const renders = Array.from(document.querySelectorAll('.message-render'));
const host = [...renders].reverse().find((el) => (el.textContent ?? '').includes(text));
if (!host) {
throw new Error(`No message contains: ${text}`);
}
const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {
const value = node.nodeValue ?? '';
const index = value.indexOf(text);
if (index !== -1) {
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + text.length);
const selection = window.getSelection();
if (!selection) {
throw new Error('Selection API unavailable');
}
selection.removeAllRanges();
selection.addRange(range);
if (withMouse) {
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
}
return;
}
node = walker.nextNode();
}
throw new Error(`No text node contains: ${text}`);
},
{ text: needle, emitMouseUp },
);
}
/**
* Viewport coordinates of the first character of `needle` inside the most
* recent message containing it. Measuring the `needle` text node itself (not
* the first text node in `.message-render`, which may be a `select-none`
* screen-reader/model-label header) keeps the gesture on the actual reply word,
* not metadata or whitespace.
*/
function measureNeedle(page: Page, needle: string) {
return page.evaluate((text) => {
const renders = Array.from(document.querySelectorAll('.message-render'));
const host = [...renders].reverse().find((el) => (el.textContent ?? '').includes(text));
if (!host) {
throw new Error(`No message contains: ${text}`);
}
const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node && !(node.nodeValue ?? '').includes(text)) {
node = walker.nextNode();
}
if (!node) {
throw new Error(`No text node contains: ${text}`);
}
const index = (node.nodeValue ?? '').indexOf(text);
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + 1);
const r = range.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
}, needle);
}
/**
* Double-click the first word of `needle` using native mouse events. Unlike
* `selectMessageText` (a programmatic Range), this exercises the browser's own
* double-click word selection — the path the `dblclick` listener guards.
*/
async function doubleClickWord(page: Page, needle: string) {
const point = await measureNeedle(page, needle);
await page.mouse.dblclick(point.x, point.y);
}
/**
* Wait for the reply to stop re-rendering before selecting text inside it.
*
* Distinct from the settle window `QuoteButton` applies to a *selection*: this
* is the reply itself still streaming. A markdown re-render swaps out the text
* node the selection points at, which collapses it — so a double-click landing
* mid-stream loses its selection before the popup can be clicked, and every
* `toPass` retry loses the same race rather than recovering from it.
*
* `sendMessage` resolves on the stream *response*, not on the final render, so
* the wait has to be explicit.
*/
async function waitForReplyToSettle(page: Page, needle: string) {
const readReply = () =>
page.evaluate((text) => {
const renders = Array.from(document.querySelectorAll('.message-render'));
const host = [...renders].reverse().find((el) => (el.textContent ?? '').includes(text));
return host?.textContent ?? '';
}, needle);
await expect(async () => {
const before = await readReply();
await page.waitForTimeout(250);
expect(await readReply()).toBe(before);
}).toPass({ timeout: 20000 });
}
/**
* Triple-click the block containing `needle` with native mouse events, which
* makes Chromium select that whole block and park the selection's far boundary
* at the start of the *next* one. For a message's closing block that boundary
* lands outside `.message-render`, on the composer wrapper — the case that used
* to suppress the popup even though no text outside the message was selected.
*/
async function tripleClickText(page: Page, needle: string) {
const point = await measureNeedle(page, needle);
await page.mouse.click(point.x, point.y, { clickCount: 3 });
}
/**
* Select from inside one message through into the next, then `mouseup`. This is
* a genuine cross-message drag — text outside the message really is selected —
* and must never produce a quote, however the boundary clamping treats the
* block-overhang cases around it.
*/
async function selectAcrossMessages(page: Page, fromNeedle: string, toNeedle: string) {
await page.evaluate(
({ from, to }) => {
const findText = (text: string) => {
const renders = Array.from(document.querySelectorAll('.message-render'));
const host = [...renders].reverse().find((el) => (el.textContent ?? '').includes(text));
if (!host) {
throw new Error(`No message contains: ${text}`);
}
const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node && !(node.nodeValue ?? '').includes(text)) {
node = walker.nextNode();
}
if (!node) {
throw new Error(`No text node contains: ${text}`);
}
return { node, index: (node.nodeValue ?? '').indexOf(text) };
};
const start = findText(from);
const end = findText(to);
const range = document.createRange();
range.setStart(start.node, start.index);
range.setEnd(end.node, end.index + to.length);
const selection = window.getSelection();
if (!selection) {
throw new Error('Selection API unavailable');
}
selection.removeAllRanges();
selection.addRange(range);
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
},
{ from: fromNeedle, to: toNeedle },
);
}
/** Viewport-relative bottom edge of the live selection. */
function selectionBottom(page: Page) {
return page.evaluate(() => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return null;
}
return selection.getRangeAt(0).getBoundingClientRect().bottom;
});
}
/**
* Scroll the message list so the block containing `needle` sits at `fraction` of
* the visible height, returning the signed distance moved.
*
* Specs move the selection between two *visible* positions rather than nudging
* blindly by a pixel count. Blind nudges kept scrolling the selection under the
* composer, where the popup correctly hides — real behaviour, but the opposite
* of what a "popup follows the scroll" spec means to assert, and the chat's own
* auto-scroll made where it landed unpredictable.
*
* The scroller is reached from the message itself rather than by querying
* `.scrollbar-gutter-stable` directly: the nav and side panels carry that class
* too, so a document-wide query can return a sidebar list that never scrolls —
* which is exactly how these specs passed locally and moved 0px in CI. This
* mirrors how the app resolves the same container (`MessageNav.tsx`).
*/
function scrollSelectionTo(page: Page, needle: string, fraction: number) {
return page.evaluate(
({ text, at }) => {
const renders = Array.from(document.querySelectorAll('.message-render'));
const host = [...renders].reverse().find((el) => (el.textContent ?? '').includes(text));
if (!host) {
throw new Error(`No message contains: ${text}`);
}
const scroller = host.closest('.scrollbar-gutter-stable');
if (!scroller) {
throw new Error('Message is not inside a scroll container');
}
const room = scroller.scrollHeight - scroller.clientHeight;
if (room <= 0) {
throw new Error(
`Message list does not overflow (scrollHeight ${scroller.scrollHeight}, clientHeight ${scroller.clientHeight})`,
);
}
const blocks = Array.from(host.querySelectorAll('p, li, td, th, pre'));
const target = blocks.find((el) => (el.textContent ?? '').includes(text)) ?? host;
const targetBox = target.getBoundingClientRect();
const scrollerBox = scroller.getBoundingClientRect();
const wanted = scrollerBox.top + scrollerBox.height * at;
const delta = targetBox.top + targetBox.height / 2 - wanted;
const start = scroller.scrollTop;
scroller.scrollTop = Math.min(room, Math.max(0, start + delta));
return scroller.scrollTop - start;
},
{ text: needle, at: fraction },
);
}
const addToChat = (page: Page) => page.getByTestId('add-to-chat-button');
const pendingChips = (page: Page) => page.getByTestId('pending-quote-chips');
const messageQuotes = (page: Page) => messagesView(page).getByTestId('message-quotes');
/** A phone's context options, minus `defaultBrowserType` — Playwright refuses
* that one inside a describe group because it would force a separate worker,
* and this suite already runs on the Chromium it asks for. */
const PIXEL_5 = {
userAgent: devices['Pixel 5'].userAgent,
viewport: devices['Pixel 5'].viewport,
deviceScaleFactor: devices['Pixel 5'].deviceScaleFactor,
isMobile: devices['Pixel 5'].isMobile,
hasTouch: devices['Pixel 5'].hasTouch,
};
/** Prompt whose mock reply renders as several paragraphs, so a spec can act on
* the message's *closing* block and can scroll a reply taller than a phone. */
const PARAGRAPHS_PROMPT = 'E2E_PARAGRAPHS_REPLY';
const OPENING_PARAGRAPH = 'E2E opening paragraph';
const CLOSING_PARAGRAPH = 'E2E closing paragraph';
/** Sits inside `.markdown-table-wrapper`, a scroll container nested in the message. */
const TABLE_CELL = 'E2E table cell text';
/** Comfortably longer than the component's 300ms selection-settle interval. */
const SETTLE_OBSERVATION_MS = 1500;
/** Seed a conversation whose latest reply has several paragraphs. */
async function seedParagraphReply(page: Page) {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const response = await sendMessage(page, PARAGRAPHS_PROMPT);
expect(response.ok()).toBeTruthy();
await expect(messagesView(page).getByText(CLOSING_PARAGRAPH)).toBeVisible({ timeout: 20000 });
}
/** The mock model echoes this when a blockquote containing the token reached the prompt. */
const QUOTE_ASSERTION_PASSED = 'E2E quote assertion passed: reply';
/**
* Select `needle` inside a message and add it as a quote, asserting the pending
* chip count reaches `expectedCount`. Retried as a unit: a pending selection is
* dismissed on any scroll/layout shift (e.g. auto-scroll after a new message),
* which is correct UX but races with a scripted select+click — `toPass` re-runs
* the select+click until the chip commits. Dedup keeps re-runs idempotent.
*/
async function addQuote(page: Page, needle: string, expectedCount: number) {
await expect(async () => {
await selectMessageText(page, needle);
const button = addToChat(page);
await expect(button).toBeVisible({ timeout: 3000 });
await button.click();
await expect(pendingChips(page)).toHaveAttribute('data-quote-count', String(expectedCount));
}).toPass({ timeout: 30000 });
}
test.describe('quote references', () => {
test('merges a quoted excerpt into the model turn, pins it to the message, and persists across reload', async ({
page,
}) => {
test.setTimeout(120000);
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
// Seed an assistant reply to quote.
let response = await sendMessage(page, 'seed for quote');
expect(response.ok()).toBeTruthy();
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
// Select the reply text -> popup -> chip above the composer.
await addQuote(page, MOCK_REPLY_TEXT, 1);
await expect(pendingChips(page)).toContainText(MOCK_REPLY_TEXT);
// Send a turn that asks the mock model to confirm the quote reached the prompt.
response = await sendMessage(page, 'E2E_ASSERT_QUOTE:reply');
expect(response.ok()).toBeTruthy();
// The model received the merged blockquote (verified server-side by the mock).
await expect(messagesView(page).getByText(QUOTE_ASSERTION_PASSED)).toBeVisible({
timeout: 20000,
});
// Pending chips drained; the reference is pinned to the sent user message.
await expect(pendingChips(page)).toHaveCount(0);
await expect(messageQuotes(page)).toContainText(MOCK_REPLY_TEXT);
// Round-trips through the DB: still pinned after reload.
await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 });
const conversationUrl = page.url();
await page.reload({ timeout: 10000 });
await expect(page).toHaveURL(conversationUrl);
await expect(messageQuotes(page)).toContainText(MOCK_REPLY_TEXT);
});
test('summons the popup from a native double-click word selection', async ({ page }) => {
test.setTimeout(120000);
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const response = await sendMessage(page, 'seed for dblclick');
expect(response.ok()).toBeTruthy();
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
await waitForReplyToSettle(page, MOCK_REPLY_TEXT);
// A real double-click selects the word under the cursor. Chromium commits
// that selection on `dblclick`, AFTER `mouseup` fires, so only a `dblclick`
// listener catches it — a programmatic Range (the other tests) would bypass
// this path entirely. Retried as a unit: auto-scroll can clear a fresh
// selection, which races the scripted double-click.
await expect(async () => {
await doubleClickWord(page, MOCK_REPLY_TEXT);
const button = addToChat(page);
await expect(button).toBeVisible({ timeout: 3000 });
await button.click();
await expect(pendingChips(page)).toHaveAttribute('data-quote-count', '1');
}).toPass({ timeout: 30000 });
// The quoted excerpt is a word from the reply, not empty.
await expect(pendingChips(page)).toContainText(/E2E|mock|reply/i);
});
test("summons the popup from a triple-click on the reply's closing paragraph", async ({
page,
}) => {
test.setTimeout(120000);
await seedParagraphReply(page);
// Triple-clicking any *earlier* paragraph always worked, because the
// selection's far boundary landed on the next paragraph — still inside the
// message. On the closing paragraph that boundary escapes `.message-render`
// and used to suppress the popup entirely, even though the user selected
// nothing outside the message.
await messagesView(page).getByText(CLOSING_PARAGRAPH).scrollIntoViewIfNeeded();
await expect(async () => {
await tripleClickText(page, CLOSING_PARAGRAPH);
const button = addToChat(page);
await expect(button).toBeVisible({ timeout: 3000 });
await button.click();
await expect(pendingChips(page)).toHaveAttribute('data-quote-count', '1');
}).toPass({ timeout: 30000 });
// The excerpt is the closing paragraph itself, not the overhang.
await expect(pendingChips(page)).toContainText(CLOSING_PARAGRAPH);
});
test('still refuses a selection that really spans two messages', async ({ page }) => {
test.setTimeout(120000);
await seedParagraphReply(page);
// Clamping the block-boundary overhang must not soften this: here visible
// text from both the user's message and the reply is selected.
await selectAcrossMessages(page, PARAGRAPHS_PROMPT, OPENING_PARAGRAPH);
await expect(addToChat(page)).toBeHidden({ timeout: 5000 });
});
test('keeps the popup pinned to the selection while the chat scrolls', async ({ page }) => {
test.setTimeout(120000);
// A short viewport guarantees the reply overflows and can actually scroll.
await page.setViewportSize({ width: 900, height: 500 });
await seedParagraphReply(page);
await expect(async () => {
await scrollSelectionTo(page, OPENING_PARAGRAPH, 0.75);
await selectMessageText(page, OPENING_PARAGRAPH);
await expect(addToChat(page)).toBeVisible({ timeout: 3000 });
}).toPass({ timeout: 30000 });
const before = await addToChat(page).boundingBox();
expect(before).not.toBeNull();
// Scrolling used to dismiss the popup on the first event, which the chat's
// own auto-scroll fires constantly while streaming. It now follows instead.
const moved = await scrollSelectionTo(page, OPENING_PARAGRAPH, 0.25);
expect(Math.abs(moved)).toBeGreaterThan(0);
await expect(addToChat(page)).toBeVisible();
await expect(async () => {
const after = await addToChat(page).boundingBox();
expect(after).not.toBeNull();
expect(Math.abs(after!.y - before!.y)).toBeGreaterThan(Math.abs(moved) / 2);
}).toPass({ timeout: 5000 });
// Still the right excerpt after travelling with the text.
await addToChat(page).click();
await expect(pendingChips(page)).toContainText(OPENING_PARAGRAPH);
});
test('hides the popup when a selection inside a table scrolls out of the chat', async ({
page,
}) => {
test.setTimeout(120000);
await page.setViewportSize({ width: 900, height: 500 });
await seedParagraphReply(page);
// Table cells live in `.markdown-table-wrapper`, a scroll container nested
// inside the message. Honouring only the nearest clipper would let the outer
// list carry the whole table under the header while the wrapper still
// reported the selection visible, stranding the popup over the composer.
await expect(async () => {
await scrollSelectionTo(page, TABLE_CELL, 0.5);
await selectMessageText(page, TABLE_CELL);
await expect(addToChat(page)).toBeVisible({ timeout: 3000 });
}).toPass({ timeout: 30000 });
const scrolledAway = await page.evaluate(() => {
const message = document.querySelector('.message-render');
const scroller = message?.closest('.scrollbar-gutter-stable');
if (!scroller) {
throw new Error('No message scroll container');
}
const start = scroller.scrollTop;
scroller.scrollTop = scroller.scrollHeight;
return scroller.scrollTop - start;
});
expect(Math.abs(scrolledAway)).toBeGreaterThan(0);
await expect(addToChat(page)).toBeHidden({ timeout: 5000 });
});
test('hides the popup when a table is scrolled sideways past the selection', async ({ page }) => {
test.setTimeout(120000);
await page.setViewportSize({ width: 900, height: 500 });
await seedParagraphReply(page);
// A wide table scrolls inside the message, so the selected cell can leave
// view without the message moving at all. Judging visibility from the
// message's ancestors, or on the vertical axis alone, would miss this
// entirely and leave the popup pinned beside a cell that is no longer there.
await expect(async () => {
await scrollSelectionTo(page, TABLE_CELL, 0.5);
await selectMessageText(page, TABLE_CELL);
await expect(addToChat(page)).toBeVisible({ timeout: 3000 });
}).toPass({ timeout: 30000 });
const scrolledSideways = await page.evaluate(() => {
const wrapper = document.querySelector('.markdown-table-wrapper');
if (!wrapper) {
throw new Error('No table wrapper');
}
const room = wrapper.scrollWidth - wrapper.clientWidth;
if (room <= 0) {
throw new Error(
`Table does not overflow sideways (scrollWidth ${wrapper.scrollWidth}, clientWidth ${wrapper.clientWidth})`,
);
}
wrapper.scrollLeft = room;
return wrapper.scrollLeft;
});
expect(scrolledSideways).toBeGreaterThan(0);
await expect(addToChat(page)).toBeHidden({ timeout: 5000 });
});
test('hides the popup when the selection collapses without a mouse event', async ({ page }) => {
test.setTimeout(120000);
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const response = await sendMessage(page, 'seed for collapse');
expect(response.ok()).toBeTruthy();
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
await expect(async () => {
await doubleClickWord(page, MOCK_REPLY_TEXT);
await expect(addToChat(page)).toBeVisible({ timeout: 3000 });
}).toPass({ timeout: 30000 });
// Collapse the selection the way a streaming markdown re-render does —
// dropping the selected text node fires only `selectionchange`, not a
// mouse/key event. The popup must not linger over the now-empty caret.
await page.evaluate(() => window.getSelection()?.collapseToEnd());
await expect(addToChat(page)).toBeHidden({ timeout: 5000 });
});
test('collapses multiple selections into one chip with a hover popup, and removes one', async ({
page,
}) => {
test.setTimeout(120000);
const firstMessage = 'quote target alpha';
const popup = page.getByTestId('quote-selections-popup');
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const response = await sendMessage(page, firstMessage);
expect(response.ok()).toBeTruthy();
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
// First the assistant reply, then the user's own message.
await addQuote(page, MOCK_REPLY_TEXT, 1);
await addQuote(page, firstMessage, 2);
// Composer shows a single collapsed "2 selections" chip, not a row of two.
await expect(pendingChips(page)).toContainText('2 selections');
// Hovering the collapsed chip reveals a popup listing every excerpt.
await pendingChips(page).getByRole('listitem').hover();
await expect(popup).toBeVisible({ timeout: 5000 });
await expect(popup).toContainText(MOCK_REPLY_TEXT);
await expect(popup).toContainText(firstMessage);
// Remove the reply from the popup; one selection remains and collapses back
// to its excerpt text.
await popup
.getByRole('listitem')
.filter({ hasText: MOCK_REPLY_TEXT })
.getByRole('button', { name: /remove quote/i })
.click();
await expect(pendingChips(page)).toHaveAttribute('data-quote-count', '1');
await expect(pendingChips(page)).toContainText(firstMessage);
await expect(pendingChips(page)).not.toContainText(MOCK_REPLY_TEXT);
// Send; only the remaining quote pins to the new user message.
const followUp = await sendMessage(page, 'expand on this');
expect(followUp.ok()).toBeTruthy();
await expect(messageQuotes(page)).toContainText(firstMessage);
await expect(messageQuotes(page)).not.toContainText(MOCK_REPLY_TEXT);
});
test('opens the selections popup via keyboard and closes on Escape', async ({ page }) => {
test.setTimeout(120000);
const firstMessage = 'quote target beta';
const popup = page.getByTestId('quote-selections-popup');
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const response = await sendMessage(page, firstMessage);
expect(response.ok()).toBeTruthy();
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
await addQuote(page, MOCK_REPLY_TEXT, 1);
await addQuote(page, firstMessage, 2);
// The collapsed pill is a focusable disclosure: focus + Enter opens it.
const trigger = pendingChips(page).getByRole('button', { name: '2 selections' });
await trigger.focus();
await expect(trigger).toBeFocused();
await page.keyboard.press('Enter');
await expect(popup).toBeVisible({ timeout: 5000 });
await expect(popup).toContainText(MOCK_REPLY_TEXT);
await expect(popup).toContainText(firstMessage);
// Opening via keyboard moves focus into the popup (first excerpt's remove ×).
await expect(popup.getByRole('button').first()).toBeFocused();
// Escape closes it and returns focus to the composer (NOT the page top, the
// bug this guards against). `document.activeElement` must be a real control.
await page.keyboard.press('Escape');
await expect(popup).toBeHidden();
await expect(page.getByRole('textbox', { name: 'Message input' })).toBeFocused();
const focusTag = await page.evaluate(() => document.activeElement?.tagName ?? 'NONE');
expect(focusTag).not.toBe('BODY');
});
test('re-merges a persisted quote into later-turn history (durable)', async ({ page }) => {
test.setTimeout(120000);
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
// Turn 1: quote the assistant reply and send a (labeled) message carrying it.
let response = await sendMessage(page, 'seed for durable');
expect(response.ok()).toBeTruthy();
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
await addQuote(page, MOCK_REPLY_TEXT, 1);
response = await sendMessage(page, 'E2E_REPLY:carryquote');
expect(response.ok()).toBeTruthy();
// Wait for turn 1's generation to fully finish before sending again — a new
// submit is blocked while the prior turn is still streaming.
await expect(messagesView(page).getByText('E2E reply carryquote')).toBeVisible({
timeout: 20000,
});
await expect(messageQuotes(page)).toContainText(MOCK_REPLY_TEXT);
// Turn 2: no new quote. The prior quoted turn must be re-merged into history,
// so the model still receives the blockquote on this later turn.
response = await sendMessage(page, 'E2E_ASSERT_QUOTE:reply');
expect(response.ok()).toBeTruthy();
await expect(messagesView(page).getByText(QUOTE_ASSERTION_PASSED)).toBeVisible({
timeout: 20000,
});
});
});
/**
* A phone reaches this feature through a completely different event path than a
* desktop: there is no `mouseup` to hang the popup off, and the tap that would
* accept it is also the gesture that dismisses the selection. Both halves are
* covered here on an emulated Pixel 5 with a real touchscreen.
*
* Headless Chromium has no Android/iOS long-press-to-select gesture, so each
* test presses with the touchscreen (which is what marks the selection as
* touch-driven, and is the only pointer event a long-press delivers) and then
* places the selection directly. What reaches the component is exactly what a
* real phone leaves behind: a selection announced by `selectionchange` alone,
* with no mouse event anywhere in the sequence.
*/
test.describe('quote references on touch devices', () => {
test.use(PIXEL_5);
/** Long-press equivalent: touch the text, then select it without any mouse event. */
async function touchSelect(page: Page, needle: string) {
await messagesView(page).getByText(needle).scrollIntoViewIfNeeded();
const point = await measureNeedle(page, needle);
/** Drop any earlier selection *before* the press. Chromium answers a
* synthetic tap with compatibility mouse events, and a leftover selection
* would let that `mouseup` summon the popup down the desktop path —
* passing this spec for a reason no phone ever reproduces. Cleared first,
* the press carries only its touch pointer, and the selection that follows
* is announced by `selectionchange` alone. */
await page.evaluate(() => window.getSelection()?.removeAllRanges());
await page.touchscreen.tap(point.x, point.y);
await selectMessageText(page, needle, false);
}
test('summons the popup from a mouse-less touch selection and adds it by tap', async ({
page,
}) => {
test.setTimeout(120000);
await seedParagraphReply(page);
await expect(async () => {
await touchSelect(page, CLOSING_PARAGRAPH);
await expect(addToChat(page)).toBeVisible({ timeout: 5000 });
}).toPass({ timeout: 30000 });
// The OS callout (Copy/Share/Look Up) claims the space directly above a
// touch selection, so the popup takes the space below it.
const popup = await addToChat(page).boundingBox();
const bottom = await selectionBottom(page);
expect(popup).not.toBeNull();
expect(bottom).not.toBeNull();
expect(popup!.y).toBeGreaterThanOrEqual(bottom!);
// Comfortable tap target, not the compact desktop pill.
expect(popup!.height).toBeGreaterThanOrEqual(44);
// Tapping has to commit before the tap dismisses the selection out from
// under the click — the second reason this was unusable on a phone.
await addToChat(page).tap();
await expect(pendingChips(page)).toHaveAttribute('data-quote-count', '1');
await expect(pendingChips(page)).toContainText(CLOSING_PARAGRAPH);
});
test('carries a touch-selected excerpt through to the model', async ({ page }) => {
test.setTimeout(120000);
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
const seeded = await sendMessage(page, 'seed for touch quote');
expect(seeded.ok()).toBeTruthy();
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
await expect(async () => {
await touchSelect(page, MOCK_REPLY_TEXT);
const button = addToChat(page);
await expect(button).toBeVisible({ timeout: 5000 });
await button.tap();
await expect(pendingChips(page)).toHaveAttribute('data-quote-count', '1');
}).toPass({ timeout: 30000 });
// End to end from a finger: the mock model confirms the blockquote arrived.
const response = await sendMessage(page, 'E2E_ASSERT_QUOTE:reply');
expect(response.ok()).toBeTruthy();
await expect(messagesView(page).getByText(QUOTE_ASSERTION_PASSED)).toBeVisible({
timeout: 20000,
});
await expect(messageQuotes(page)).toContainText(MOCK_REPLY_TEXT);
});
test('adds nothing when a press on the popup is dragged away and released', async ({ page }) => {
test.setTimeout(120000);
await seedParagraphReply(page);
await expect(async () => {
await touchSelect(page, CLOSING_PARAGRAPH);
await expect(addToChat(page)).toBeVisible({ timeout: 5000 });
}).toPass({ timeout: 30000 });
// Committing on the press would make this gesture — starting a scroll on
// the button, or touching it and thinking better of it — add the quote
// anyway. A button has to stay cancellable.
const popup = await addToChat(page).boundingBox();
expect(popup).not.toBeNull();
const centre = { x: popup!.x + popup!.width / 2, y: popup!.y + popup!.height / 2 };
await page.evaluate(
({ x, y }) => {
const button = document.querySelector('[data-testid="add-to-chat-button"]');
if (!button) {
throw new Error('Popup is not mounted');
}
const options = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'touch' };
button.dispatchEvent(
new PointerEvent('pointerdown', { ...options, clientX: x, clientY: y }),
);
/** Released far from the button, the way a drag-away cancel ends. */
button.dispatchEvent(
new PointerEvent('pointerup', { ...options, clientX: x, clientY: y + 400 }),
);
},
{ x: centre.x, y: centre.y },
);
await expect(pendingChips(page)).toHaveCount(0);
});
test('dismisses the popup when a cancelled press took the selection with it', async ({
page,
}) => {
test.setTimeout(120000);
await seedParagraphReply(page);
await expect(async () => {
await touchSelect(page, CLOSING_PARAGRAPH);
await expect(addToChat(page)).toBeVisible({ timeout: 5000 });
}).toPass({ timeout: 30000 });
const popup = await addToChat(page).boundingBox();
expect(popup).not.toBeNull();
// A press keeps the button alive through a collapsing selection so the
// release has something to land on. When that press is then cancelled, the
// collapse it masked still has to be honoured — otherwise the popup lingers
// over a selection that no longer exists and a later tap adds a dead quote.
//
// The three steps are deliberately separate. `selectionchange` is delivered
// asynchronously, so collapsing and cancelling in one synchronous block lets
// the event arrive *after* the press has already ended — the ordinary path,
// which passes with or without the fix. Waiting for delivery in between is
// what reproduces a real press: long enough for the collapse to land while
// the press is still masking it.
const press = { x: popup!.x + popup!.width / 2, y: popup!.y + popup!.height / 2 };
await page.evaluate(({ x, y }) => {
const button = document.querySelector('[data-testid="add-to-chat-button"]');
if (!button) {
throw new Error('Popup is not mounted');
}
button.dispatchEvent(
new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
pointerId: 1,
pointerType: 'touch',
clientX: x,
clientY: y,
}),
);
}, press);
const collapseDelivered = await page.evaluate(
() =>
new Promise<boolean>((resolve) => {
const timer = setTimeout(() => resolve(false), 2000);
document.addEventListener(
'selectionchange',
() => {
clearTimeout(timer);
resolve(true);
},
{ once: true },
);
window.getSelection()?.removeAllRanges();
}),
);
expect(collapseDelivered, 'the masked collapse must reach the component').toBe(true);
await page.evaluate(() => {
const button = document.querySelector('[data-testid="add-to-chat-button"]');
if (!button) {
throw new Error('Popup was dismissed before the press ended');
}
button.dispatchEvent(
new PointerEvent('pointercancel', {
bubbles: true,
cancelable: true,
pointerId: 1,
pointerType: 'touch',
}),
);
});
await expect(addToChat(page)).toBeHidden({ timeout: 5000 });
await expect(pendingChips(page)).toHaveCount(0);
});
test('never shows a popup for a selection scrolled away during the settle wait', async ({
page,
}) => {
test.setTimeout(120000);
await seedParagraphReply(page);
// A mouse-less selection is only published once it has been quiet for the
// settle interval, and nothing is tracked until then — so a scroll inside
// that window is invisible to the re-anchoring path. Publishing without
// re-checking would clamp an off-screen reading into view and strand the
// popup over the composer.
await scrollSelectionTo(page, OPENING_PARAGRAPH, 0.5);
await selectMessageText(page, OPENING_PARAGRAPH, false);
// Just past the top edge, not all the way to the end of the conversation:
// a violent scroll re-renders the messages and drops the selection outright,
// which would hide the popup for a reason that has nothing to do with this.
const scrolledAway = await scrollSelectionTo(page, OPENING_PARAGRAPH, -0.4);
expect(Math.abs(scrolledAway)).toBeGreaterThan(0);
// The selection has to survive, or this proves nothing.
const stillSelected = await page.evaluate(() => {
const selection = window.getSelection();
return !!selection && selection.rangeCount > 0 && !selection.isCollapsed;
});
expect(stillSelected, 'the selection must outlive the scroll').toBe(true);
// Sit out the settle interval before asserting. `toBeHidden` is satisfied by
// an element that has not been created *yet*, so checking straight away
// would pass before the timer had a chance to publish anything.
await page.waitForTimeout(SETTLE_OBSERVATION_MS);
await expect(addToChat(page)).toBeHidden();
await expect(pendingChips(page)).toHaveCount(0);
});
test('survives the scroll a phone fires while selecting', async ({ page }) => {
test.setTimeout(120000);
await seedParagraphReply(page);
await expect(async () => {
await scrollSelectionTo(page, OPENING_PARAGRAPH, 0.75);
await touchSelect(page, OPENING_PARAGRAPH);
await expect(addToChat(page)).toBeVisible({ timeout: 5000 });
}).toPass({ timeout: 30000 });
// Nudging the list (the URL bar collapsing does the same via `resize`) used
// to throw the selection away before the user could reach the button.
const moved = await scrollSelectionTo(page, OPENING_PARAGRAPH, 0.25);
expect(Math.abs(moved)).toBeGreaterThan(0);
await expect(addToChat(page)).toBeVisible();
await addToChat(page).tap();
await expect(pendingChips(page)).toContainText(OPENING_PARAGRAPH);
});
});