* 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>
444 lines
20 KiB
TypeScript
444 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 below-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: "Copy" })).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;
|
|
return {
|
|
below: railBox.y >= bubbleBox.y + bubbleBox.height - 4,
|
|
alignedStart: Math.abs(railBox.x - bubbleBox.x) < 8,
|
|
};
|
|
})
|
|
.toEqual({ below: true, alignedStart: 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: "Copy" })).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 underneath the bubble, aligned to the end.
|
|
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;
|
|
return {
|
|
below: railBox.y >= frameBox.y - 4,
|
|
alignedEnd: Math.abs(railBox.x + railBox.width - (frameBox.x + frameBox.width)) < 8,
|
|
};
|
|
})
|
|
.toEqual({ below: true, alignedEnd: 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 below the bubble, aligned to the right edge.
|
|
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(railBox.x + railBox.width - (frameBox.x + frameBox.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]:not([data-message-id^="temp-"])`)
|
|
.filter({ hasText: parentText })
|
|
.first();
|
|
await expect(parentRow).toBeVisible({ timeout: 20_000 });
|
|
const parentId = await parentRow.getAttribute("data-message-id");
|
|
expect(parentId).toBeTruthy();
|
|
expect(parentId).not.toMatch(/^temp-/);
|
|
|
|
await parentRow.hover();
|
|
await parentRow.getByRole("button", { name: "Reply" }).click();
|
|
await sendComposerMessage(page, composer, replyText);
|
|
|
|
const replyRow = transcript
|
|
.locator(`[data-message-id]:not([data-message-id^="temp-"])`)
|
|
.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);
|
|
const headers = { ...response.headers() };
|
|
delete headers["content-length"];
|
|
await route.fulfill({
|
|
status: response.status(),
|
|
headers,
|
|
contentType: "application/json",
|
|
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();
|
|
});
|
|
});
|