evOo-local/apps/web/e2e/message-hover-actions.spec.ts
Claude 9d6d85cb52
test(web): look for the focus question in the conversation, not the sidebar
The main conversation's sidebar row now shows the pending intake question,
so a page-wide text match finds it twice (strict mode) or never reaches zero.
The specs mean the card in the open conversation, so they look there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 04:34:57 +00:00

440 lines
20 KiB
TypeScript

import { expect, type Locator, type Page, type Route, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, signup, waitForChatComposer } from "./helpers";
/** Submit from the Send control so an in-flight prior send cannot swallow Enter. */
async function sendComposerMessage(page: Page, composer: Locator, text: string) {
await composer.fill(text);
// An open onboarding card in the transcript has its own Send.
const send = page.getByTestId("composer-bar").getByRole("button", { name: "Send", exact: true });
await expect(send).toBeEnabled();
await send.click();
await expect(composer).toHaveValue("");
}
/** Copy is unique to the message More menu while it is open. */
function messageMoreCopyItem(page: Page) {
return page.getByRole("menuitem", { name: "Copy" });
}
async function expectMessageMoreMenu(page: Page, open: boolean) {
const copyItem = messageMoreCopyItem(page);
if (open) {
await expect(copyItem).toBeVisible();
return;
}
await expect(async () => {
if (await copyItem.isVisible()) {
await page.keyboard.press("Escape");
}
await expect(copyItem).toBeHidden({ timeout: 500 });
}).toPass({ timeout: 10_000 });
}
async function revealHoverRail(row: Locator): Promise<Locator> {
const rail = row.getByTestId("message-hover-rail");
await expect
.poll(async () => {
await row.hover();
return rail.evaluate((element) => {
const style = getComputedStyle(element);
return { opacity: style.opacity, pointerEvents: style.pointerEvents };
});
})
.toEqual({ opacity: "1", pointerEvents: "auto" });
// Incoming replies can move the row away from the pointer; keep real focus for screenshots.
await rail.getByRole("button", { name: "More" }).focus();
return rail;
}
/** Park the pointer outside the message and blur focus so the rail returns to opacity-0. */
async function expectRailAtRest(page: Page, row: Locator) {
const rail = row.getByTestId("message-hover-rail");
const box = await row.boundingBox();
if (box) {
// (0,0) can still sit on the first transcript row; leave below the row instead.
await page.mouse.move(Math.max(0, box.x) + 8, box.y + box.height + 32);
}
// More keeps focus after Escape; blur so focus-within does not leave the rail visible.
await page.evaluate(() => {
(document.activeElement as HTMLElement | null)?.blur();
});
await expect(rail).toHaveCSS("opacity", "0");
await expect(rail).toHaveCSS("pointer-events", "none");
await expect(row.getByTestId("message-hover-time")).toHaveCSS("opacity", "0");
}
test("message hover shows beside-bubble actions; reply links to parent", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `hover-actions-${stamp}@rakazo.test`, "password12", "Hover Actions");
await completeOnboarding(page, undefined, { mainConversation: true });
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
const transcript = page.getByTestId("transcript");
// Empty start: no Fresh-start greeting. Answer the focus card, then ask something long: a bot
// row's rail sits at the row's end, so the measured reply must wrap and fill its row.
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toBeVisible({
timeout: 20_000,
});
await page.getByTestId("focus-choice-day").click();
await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible({
timeout: 20_000,
});
const composer = page.getByRole("combobox", { name: /^Message/ });
await sendComposerMessage(
page,
composer,
`hover-bot-${stamp} ${"please look into this ".repeat(7)}`,
);
const botText = page.getByText(/i handled: hover-bot-/);
await expect(transcript.getByText(/i handled: hover-bot-/)).toBeVisible({ timeout: 20_000 });
const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first();
await expect(botRow).toBeVisible();
await expectRailAtRest(page, botRow);
await captureScreenshot(page, testInfo, "message-actions-rest-desktop");
const botRail = await revealHoverRail(botRow);
const botToolbar = botRow.getByTestId("message-hover-actions");
const botTime = botRow.getByTestId("message-hover-time");
await expect(botTime).toHaveCSS("opacity", "1");
const botTimeBox = await botTime.boundingBox();
const botRowBox = await botRow.boundingBox();
expect(botTimeBox).not.toBeNull();
expect(botRowBox).not.toBeNull();
expect(
Math.abs(botTimeBox!.x + botTimeBox!.width - botRowBox!.x - botRowBox!.width),
).toBeLessThan(2);
await expect(botToolbar.getByRole("button", { name: "Reply" })).toBeVisible();
await expect(botToolbar.getByRole("button", { name: "More" })).toBeVisible();
// Measure a unique visible bubble, so an oversized wrapper cannot hide a gap.
const botBubble = botRow.getByTestId("message-bot-bubble").first();
await expect
.poll(async () => {
const railBox = await botRail.boundingBox();
const bubbleBox = await botBubble.boundingBox();
if (!railBox || !bubbleBox) return null;
const railMid = railBox.y + railBox.height / 2;
const bubbleMid = bubbleBox.y + bubbleBox.height / 2;
return {
beside: railBox.x >= bubbleBox.x + bubbleBox.width - 2,
flush: railBox.x - (bubbleBox.x + bubbleBox.width) < 8,
centered: Math.abs(railMid - bubbleMid) <= 12,
notBelow: railBox.y + railBox.height <= bubbleBox.y + bubbleBox.height + 12,
};
})
.toEqual({ beside: true, flush: true, centered: true, notBelow: true });
await captureScreenshot(page, testInfo, "message-bot-actions-desktop");
await expectRailAtRest(page, botRow);
const parentText = `hover-parent-${stamp}`;
const replyText = `hover-reply-${stamp}`;
await expect(composer).toBeVisible();
await sendComposerMessage(page, composer, parentText);
const parentRow = transcript.locator(`[data-message-id]`).filter({ hasText: parentText }).first();
await expect(parentRow).toBeVisible({ timeout: 20_000 });
const rail = await revealHoverRail(parentRow);
const toolbar = parentRow.getByTestId("message-hover-actions");
await expect(toolbar.getByRole("button", { name: "Reply" })).toBeVisible();
await expect(toolbar.getByRole("button", { name: "More" })).toBeVisible();
const react = toolbar.getByRole("button", { name: "React" });
await expect(react).toBeVisible();
// Default reaction matches Reply/More: muted control color, not yellow.
await expect
.poll(async () => {
const mutedColor = await toolbar
.getByRole("button", { name: "More" })
.evaluate((el) => getComputedStyle(el).color);
const reactionColor = await react.evaluate((el) => getComputedStyle(el).color);
return reactionColor === mutedColor;
})
.toBe(true);
// User bubble (right): icons sit to the left, vertically centered — not under the bubble.
const frame = parentRow.getByTestId("message-bubble-frame");
await expect
.poll(async () => {
const railBox = await rail.boundingBox();
const frameBox = await frame.boundingBox();
if (!railBox || !frameBox) return null;
const railMid = railBox.y + railBox.height / 2;
const frameMid = frameBox.y + frameBox.height / 2;
return {
beside: railBox.x + railBox.width <= frameBox.x + 2,
flush: frameBox.x - (railBox.x + railBox.width) < 8,
centered: Math.abs(railMid - frameMid) <= 12,
notBelow: railBox.y >= frameBox.y - 12,
};
})
.toEqual({ beside: true, flush: true, centered: true, notBelow: true });
// User bubble is a muted elevated surface (not cream invert / bright pill).
const userSurface = parentRow.getByTestId("message-user-bubble");
await expect(userSurface).toBeVisible();
await expect
.poll(async () => userSurface.evaluate((el) => getComputedStyle(el).backgroundColor))
.not.toMatch(/^rgb\(241,\s*241,\s*239\)$/); // not cream primary foreground
// Long user bubble: rail stays ~6px beside the bubble edge, not the full row width.
const longText = `hover-long-${stamp}-${"x".repeat(220)}`;
await sendComposerMessage(page, composer, longText);
const longRow = transcript
.locator(`[data-message-id]`)
.filter({ hasText: longText.slice(0, 40) })
.first();
await expect(longRow).toBeVisible({ timeout: 20_000 });
const longRail = await revealHoverRail(longRow);
const longFrame = longRow.getByTestId("message-bubble-frame");
await expect
.poll(async () => {
const railBox = await longRail.boundingBox();
const frameBox = await longFrame.boundingBox();
if (!railBox || !frameBox) return null;
return Math.abs(frameBox.x - (railBox.x + railBox.width));
})
.toBeLessThan(8);
// Time appears at the opposite row edge on hover, outside More.
await revealHoverRail(parentRow);
const rowTime = parentRow.getByTestId("message-hover-time");
await expect(rowTime).toHaveCSS("opacity", "1");
await expect(rowTime).toHaveText(/\d/);
const timeBox = await rowTime.boundingBox();
const rowBox = await parentRow.boundingBox();
expect(timeBox).not.toBeNull();
expect(rowBox).not.toBeNull();
expect(Math.abs(timeBox!.x - rowBox!.x)).toBeLessThan(2);
const moreButton = toolbar.getByRole("button", { name: "More" });
await moreButton.click();
await expectMessageMoreMenu(page, true);
await expect(page.getByRole("menu").locator("time")).toHaveCount(0);
// Escape closes More; the hover rail stays visible for the pinned row.
await expectMessageMoreMenu(page, false);
await expect(moreButton).toBeVisible();
await captureScreenshot(page, testInfo, "message-user-actions-hover-desktop");
await page.getByRole("combobox", { name: "Message" }).focus();
// Default transcript shot: rail at rest (no hover pin, mouse clear).
await expectRailAtRest(page, parentRow);
await captureScreenshot(page, testInfo, "message-user-bubble-desktop");
// Clip shot of bubble + rail for gallery geometry.
await revealHoverRail(parentRow);
const railBox = await rail.boundingBox();
const frameBox = await frame.boundingBox();
if (!railBox || !frameBox) throw new Error("missing hover toolbar geometry");
const pad = 16;
const top = Math.min(railBox.y, frameBox.y);
const clip = {
x: Math.max(0, Math.min(railBox.x, frameBox.x) - pad),
y: Math.max(0, top - pad),
width:
Math.max(railBox.x + railBox.width, frameBox.x + frameBox.width) -
Math.min(railBox.x, frameBox.x) +
pad * 2,
height: Math.max(railBox.y + railBox.height, frameBox.y + frameBox.height) - top + pad * 2,
};
const hoverPath = testInfo.outputPath("message-hover-toolbar.png");
await page.screenshot({
animations: "disabled",
caret: "hide",
clip,
path: hoverPath,
});
await testInfo.attach("message-hover-toolbar", { contentType: "image/png", path: hoverPath });
await react.click();
for (const emoji of ["👍", "👎", "❤️", "😂", "🎉", "😮"]) {
await expect(page.getByRole("button", { name: emoji, exact: true })).toBeVisible();
}
await captureScreenshot(page, testInfo, "message-reaction-picker");
await page.getByRole("button", { name: "❤️", exact: true }).click();
await expect(parentRow.getByTestId("message-reactions")).toHaveText("❤️");
await parentRow.hover();
await react.click();
await page.getByRole("button", { name: "👍", exact: true }).click();
await expect(parentRow.getByTestId("message-reactions")).toContainText("👍");
await parentRow.hover();
await react.click();
await page.getByRole("button", { name: "❤️", exact: true }).click();
await expect(parentRow.getByTestId("message-reactions")).toContainText("❤️ 2");
await captureScreenshot(page, testInfo, "message-multiple-reactions");
await page.reload({ waitUntil: "domcontentloaded" });
await waitForChatComposer(page);
await expect(parentRow).toBeVisible({ timeout: 20_000 });
await expect(parentRow.getByTestId("message-reactions")).toContainText("❤️ 2");
await expect(parentRow.getByTestId("message-reactions")).toContainText("👍");
await parentRow.hover();
await toolbar.getByRole("button", { name: "More" }).click();
await page.getByRole("menuitem", { name: "Copy" }).click();
await expect
.poll(async () => page.evaluate(() => navigator.clipboard.readText()))
.toBe(parentText);
await expect(toolbar.getByRole("button", { name: "More" })).toBeFocused();
await parentRow.hover();
await toolbar.getByRole("button", { name: "Reply" }).click();
const replyChip = page.getByTestId("reply-chip");
await expect(replyChip).toBeVisible();
await expect(replyChip).toContainText(/Replying to/);
await sendComposerMessage(page, composer, replyText);
await expect(replyChip).toHaveCount(0);
const replyRow = transcript.locator(`[data-message-id]`).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(parentText);
await captureScreenshot(page, testInfo, "message-reply-thread");
await parentPreview.click();
await expect(parentRow).toBeInViewport();
await page.setViewportSize({ width: 390, height: 844 });
await botRow.scrollIntoViewIfNeeded();
await revealHoverRail(botRow);
await captureScreenshot(page, testInfo, "message-bot-actions-mobile");
await parentRow.scrollIntoViewIfNeeded();
await revealHoverRail(parentRow);
await captureScreenshot(page, testInfo, "message-user-actions-mobile");
});
test("reply preview jumps to parent outside the loaded page", async ({ page }) => {
const stamp = Date.now();
await signup(page, `hover-page-${stamp}@rakazo.test`, "password12", "Hover Page");
await completeOnboarding(page);
const parentText = `page-parent-${stamp}`;
const replyText = `page-reply-${stamp}`;
const composer = page.getByRole("combobox", { name: /^Message/ });
await expect(composer).toBeVisible();
await sendComposerMessage(page, composer, parentText);
const transcript = page.getByTestId("transcript");
const parentRow = transcript.locator(`[data-message-id]`).filter({ hasText: parentText }).first();
await expect(parentRow).toBeVisible({ timeout: 20_000 });
const parentId = await parentRow.getAttribute("data-message-id");
expect(parentId).toBeTruthy();
await parentRow.hover();
await parentRow.getByRole("button", { name: "Reply" }).click();
await sendComposerMessage(page, composer, replyText);
const replyRow = transcript.locator(`[data-message-id]`).filter({ hasText: replyText }).first();
await expect(replyRow).toBeVisible({ timeout: 20_000 });
await expect(replyRow.getByTestId("reply-parent-preview")).toContainText(parentText);
// Simulate a paginated snapshot where the parent is older than the loaded page.
// Bootstrap and threads/get both hydrate the transcript on reload.
const stripParent = (body: {
json?: {
messages?: Array<{ id: string }>;
olderCursor?: number | null;
thread?: { messages?: Array<{ id: string }>; olderCursor?: number | null };
};
}) => {
if (body.json?.messages) {
body.json.messages = body.json.messages.filter((message) => message.id !== parentId);
body.json.olderCursor = body.json.olderCursor ?? 1;
}
if (body.json?.thread?.messages) {
body.json.thread.messages = body.json.thread.messages.filter(
(message) => message.id !== parentId,
);
body.json.thread.olderCursor = body.json.thread.olderCursor ?? 1;
}
};
// One handler for both hydrate RPCs avoids overlapping globs racing on reload
// (Playwright "Route is already handled" when fulfill runs twice).
const hydrateRpc = /\/rpc\/(bootstrap|threads\/get)(?:\?|$)/;
const stripHydrate = async (route: Route) => {
try {
const response = await route.fetch();
const body = (await response.json()) as Parameters<typeof stripParent>[0];
stripParent(body);
await route.fulfill({
status: response.status(),
headers: response.headers(),
body: JSON.stringify(body),
});
} catch (error) {
// Reload can cancel an in-flight intercepted request after fetch returns.
if (/already handled|Target closed|Request context disposed/i.test(String(error))) return;
throw error;
}
};
await page.route(hydrateRpc, stripHydrate);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.getByRole("combobox", { name: /^Message/ })).toBeVisible({ timeout: 20_000 });
await expect(page.locator(`[data-message-id="${parentId}"]`)).toHaveCount(0);
const offlinePreview = page
.locator(`[data-message-id]`)
.filter({ hasText: replyText })
.getByTestId("reply-parent-preview");
await expect(offlinePreview).toBeVisible();
await expect(offlinePreview).toHaveText("Earlier message");
await page.unroute(hydrateRpc, stripHydrate);
await offlinePreview.click();
await expect(page.locator(`[data-message-id="${parentId}"]`)).toBeVisible({ timeout: 20_000 });
await expect(page.locator(`[data-message-id="${parentId}"]`)).toContainText(parentText);
});
test.describe("touch message actions", () => {
test.use({ hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } });
test("More exposes actions without simulated hover", async ({ page }, testInfo) => {
await signup(page, `touch-actions-${Date.now()}@rakazo.test`, "password12", "Touch Actions");
await completeOnboarding(page, undefined, { mainConversation: true });
expect(
await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches),
).toBe(false);
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toBeVisible({
timeout: 20_000,
});
await page.getByTestId("focus-choice-day").click();
// Onboarding writes no text of its own, so ask for a plain reply to act on.
const composer = page.getByRole("combobox", { name: /^Message/ });
await sendComposerMessage(page, composer, `touch-bot-${Date.now()}`);
const botText = page.getByText(/i handled: touch-bot-/);
await expect(page.getByTestId("transcript").getByText(/i handled: touch-bot-/)).toBeVisible({
timeout: 20_000,
});
const row = page
.getByTestId("transcript")
.locator("[data-message-id]")
.filter({ has: botText })
.first();
const rail = row.getByTestId("message-hover-rail");
await expect(rail).toHaveCSS("opacity", "1");
await expect(rail.getByRole("button", { name: "Reply", exact: true })).toBeHidden();
await rail.getByRole("button", { name: "React", exact: true }).tap();
await expect(page.getByRole("button", { name: "🎉", exact: true })).toBeVisible();
await captureScreenshot(page, testInfo, "message-reaction-picker-touch");
await page.getByRole("button", { name: "🎉", exact: true }).tap();
await expect(row.getByTestId("message-reactions")).toHaveText("🎉");
await rail.getByRole("button", { name: "React", exact: true }).tap();
await page.getByRole("button", { name: "🎉", exact: true }).tap();
await expect(row.getByTestId("message-reactions")).toHaveText("🎉 2");
await rail.getByRole("button", { name: "More" }).tap();
await expect(page.getByRole("menuitem", { name: "Copy" })).toBeVisible();
await expect(row.getByTestId("message-hover-time")).toHaveCSS("opacity", "1");
await expect(row.getByTestId("message-hover-time")).toHaveText(/\d/);
await expect(page.getByRole("menu").locator("time")).toHaveCount(0);
await captureScreenshot(page, testInfo, "message-actions-touch-menu");
await page.getByRole("menuitem", { name: "Reply", exact: true }).tap();
await expect(page.getByRole("button", { name: "Cancel reply" })).toBeVisible();
});
});