evOo-local/apps/web/e2e/message-quote.spec.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +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]")
.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);
});