evOo-local/apps/web/e2e/message-quote.spec.ts
cynarAI ca869561ac
Voice-first streaming, non-blocking follow-ups and chat UX (#205)
* media: check ffmpeg filter support before subtitle operations

* sandbox: handle synchronous spawn errors safely in desktop sandbox

* Voice-first streaming, non-blocking follow-ups, quiet routines and chat UX

Why: questions blocked the chat, follow-up messages had no acknowledgement, message actions sat beside the bubble, and voice turn-taking added latency.

What: waiting runs accept steering messages and the bot marks each claimed follow-up as seen; unclaimed follow-ups show as queued. Reaction, copy and reply controls sit under each message. Streaming voice (VAD endpointing, barge-in that stops the run, clause-based TTS), an optimistic input queue, procedural earcons and delta-based quiet routines were added. Onboarding can be skipped straight to the main conversation. Dual-surface layout and project pickers are included. Status colors use semantic tokens.

Tested: unit suites for core, adapters, api and web pass, typecheck and Biome are clean. Browser end-to-end tests were not run locally.

* Update translation catalogs

Re-extracted catalogs for the new and moved strings; the catalog sync test passes.

* Add project plan and test readiness notes

Notes from the milestone work: feature inventory, test infrastructure and test readiness. No code changes.

* fix(ci): align scale guards, space deletion, computer toggle, and mobile expo deps

* fix(ci): restore space deletion guard, browser toggle, and run-steps contracts

* fix(ci): restore model resolution errors, group stage, and onboarding flow

* fix(ci): restore in-app-browser close button, disable stop button while sending, and sync lingui catalogs

* fix(ci): address web e2e failures for bot-crud, choice cards, message quote, and voice onboarding

* test(web): wait for avatar and rig visibility in oo-avatar motion spec

* test(web): wait for persisted message IDs in message-hover-actions pagination spec

* Onboarding: always drop the local voice when the voice step is skipped

Skipping waited only for the latest voice load and disconnected only
when the client saw the connect succeed. An earlier or client-failed
connect could still persist the local credential, so a user who
skipped voice was left with one. Wait for every load and always
disconnect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

* Shell: hide the optimistic copy once the transcript shows the send

A message sent while the bot works streamed into the transcript before
its send request resolved, so the same text showed twice until the
optimistic bubble was removed. Drop an optimistic message as soon as a
persisted user message with the same text appears.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 14:08:31 +02:00

162 lines
6.6 KiB
TypeScript

import { expect, type Locator, type Page, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
/**
* Build a real browser Selection over `startNeedle` … `endNeedle` inside `scope`
* (a row for same-message picks, the transcript for cross-message picks), then
* fire a trusted mouseup over the transcript so the app re-reads the selection.
* A lone mouse.up keeps the selection alive — a mousedown would clear it first.
* Pass `release: false` to exercise the selectionchange-only path instead.
*/
async function selectAndRelease(
page: Page,
scope: Locator,
startNeedle: string,
endNeedle = startNeedle,
options: { release?: boolean } = {},
) {
await scope.evaluate(
(el, { startNeedle, endNeedle }) => {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const nodes: Text[] = [];
let node = walker.nextNode();
while (node) {
nodes.push(node as Text);
node = walker.nextNode();
}
const find = (needle: string, fromEnd: boolean) => {
const list = fromEnd ? [...nodes].reverse() : nodes;
for (const text of list) {
const idx = text.textContent?.indexOf(needle) ?? -1;
if (idx >= 0) return { node: text, idx };
}
return null;
};
const start = find(startNeedle, false);
const end = find(endNeedle, true);
if (!start || !end) throw new Error(`selection needles not found: ${startNeedle}`);
const range = document.createRange();
range.setStart(start.node, start.idx);
range.setEnd(end.node, end.idx + endNeedle.length);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
},
{ startNeedle, endNeedle },
);
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
if (!selected.includes(startNeedle)) {
throw new Error(`selection did not stick (got: "${selected.slice(0, 80)}")`);
}
if (options.release === false) return;
const transcript = page.getByTestId("transcript");
const box = await transcript.boundingBox();
if (!box) throw new Error("transcript not laid out");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.up();
}
test("selecting a text span quotes it into a reply", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `quote-${stamp}@rakazo.test`, "password12", "Quote Tester");
await completeOnboarding(page);
const transcript = page.getByTestId("transcript");
const composer = page.getByRole("combobox", { name: /Message/ });
const quoteButton = page.getByTestId("quote-selection");
const sourceText = `quote-source-${stamp} shows forty two percent growth`;
await composer.fill(sourceText);
await composer.press("Enter");
const sourceRow = transcript
.locator("[data-message-id]:not([data-message-id^='temp-'])")
.filter({ has: page.getByTestId("message-user-bubble") })
.filter({ hasText: `quote-source-${stamp}` })
.first();
await expect(sourceRow).toBeVisible({ timeout: 20_000 });
// Selection inside one message offers the Quote action; Escape dismisses it.
await selectAndRelease(page, sourceRow, "forty two percent");
await expect(quoteButton).toBeVisible();
await captureScreenshot(page, testInfo, "message-quote-selection");
await page.keyboard.press("Escape");
await expect(quoteButton).toHaveCount(0);
// A selection with no mouse release (keyboard, assistive tech) still offers
// Quote — the affordance hangs off selectionchange, not mouseup.
await selectAndRelease(page, sourceRow, "forty two percent", "forty two percent", {
release: false,
});
await expect(quoteButton).toBeVisible();
await page.keyboard.press("Escape");
await expect(quoteButton).toHaveCount(0);
// Quoting arms the existing reply flow with the excerpt in the chip.
await selectAndRelease(page, sourceRow, "forty two percent");
await expect(quoteButton).toBeVisible();
await quoteButton.click();
const replyChip = page.getByTestId("reply-chip");
await expect(replyChip).toBeVisible();
await expect(replyChip).toContainText(/Replying to/);
await expect(replyChip).toContainText("forty two percent");
const replyText = `quote-reply-${stamp} why this number?`;
await composer.fill(replyText);
await composer.press("Enter");
await expect(replyChip).toHaveCount(0);
// The sent message shows the excerpt and keeps jump-to-source.
const replyRow = transcript
.locator("[data-message-id]")
.filter({ has: page.getByTestId("message-user-bubble") })
.filter({ hasText: replyText })
.first();
await expect(replyRow).toBeVisible({ timeout: 20_000 });
const parentPreview = replyRow.getByTestId("reply-parent-preview");
await expect(parentPreview).toBeVisible();
await expect(parentPreview).toContainText("forty two percent");
await expect(parentPreview).not.toContainText("quote-source");
await captureScreenshot(page, testInfo, "message-quote-reply");
await parentPreview.click();
await expect(sourceRow).toBeInViewport();
// The excerpt is persisted — it still renders after a full reload.
await page.reload();
const reloadedRow = transcript
.locator("[data-message-id]")
.filter({ has: page.getByTestId("message-user-bubble") })
.filter({ hasText: replyText })
.first();
await expect(reloadedRow).toBeVisible({ timeout: 20_000 });
await expect(reloadedRow.getByTestId("reply-parent-preview")).toContainText("forty two percent");
});
test("a selection spanning two messages offers no quote action", async ({ page }) => {
const stamp = Date.now();
await signup(page, `quote-span-${stamp}@rakazo.test`, "password12", "Quote Tester");
await completeOnboarding(page);
const transcript = page.getByTestId("transcript");
const composer = page.getByRole("combobox", { name: /Message/ });
// The scripted bot echoes user text; scope to user bubbles for unique rows.
const userRow = (text: string) =>
transcript
.locator("[data-message-id]")
.filter({ has: page.getByTestId("message-user-bubble") })
.filter({ hasText: text })
.first();
const firstText = `quote-first-${stamp}`;
const secondText = `quote-second-${stamp}`;
await composer.fill(firstText);
await composer.press("Enter");
await expect(userRow(firstText)).toBeVisible({ timeout: 20_000 });
await composer.fill(secondText);
await composer.press("Enter");
await expect(userRow(secondText)).toBeVisible({ timeout: 20_000 });
await selectAndRelease(page, transcript, firstText, secondText);
await expect(page.getByTestId("quote-selection")).toHaveCount(0);
});