From 235bae0cc2f84b8704db21f1ff1ef908edabddb4 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 26 Aug 2026 17:54:40 +1000 Subject: [PATCH] test(desktop): cover inbox agent header alignment Signed-off-by: Matt Toohey --- .../src/features/home/ui/InboxMessageRow.tsx | 2 +- desktop/tests/e2e/messaging.spec.ts | 315 ++++++++++++++---- 2 files changed, 250 insertions(+), 67 deletions(-) diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 52667967cb..576e6406d9 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -236,7 +236,7 @@ export function InboxMessageRow({ start of a line on its own. Gap matches the container's, so spacing reads the same either side of the divider. */} - + {timestampNode} diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index a4ec67b175..83d8d0a472 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -1,7 +1,8 @@ import { readFileSync } from "node:fs"; -import { expect, test, type Locator } from "@playwright/test"; +import { expect, test, type Locator, type Page } from "@playwright/test"; +import type { RelayEvent } from "../../src/shared/api/types"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; @@ -49,16 +50,230 @@ async function expectThreadReplyUnobscured(row: Locator) { .toBe(true); } -async function measureInlineBaseline(locator: Locator) { - return locator.evaluate((element) => { - const marker = document.createElement("span"); - marker.style.cssText = - "display:inline-block;width:0;height:0;margin:0;padding:0;border:0"; - element.append(marker); - const baseline = marker.getBoundingClientRect().top; - marker.remove(); - return baseline; +const FONT_SIZES = ["smaller", "default", "larger"] as const; +const GEOMETRY_TOLERANCE_PX = 0.25; +const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + +type MockInboxFeedItem = Pick< + RelayEvent, + "content" | "created_at" | "id" | "kind" | "pubkey" | "tags" +> & { + category: "mention"; + channel_id: string; + channel_name: string; +}; + +type MockInboxWindow = Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + id?: string; + mentionPubkeys?: string[]; + pubkey?: string; + }) => RelayEvent; + __BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: MockInboxFeedItem) => unknown; +}; + +async function waitForStableAgentHeader(row: Locator, timestampTestId: string) { + await row.evaluate(async (element, timestampId) => { + await document.fonts.ready; + + const targets = [ + element.querySelector('[data-testid="message-author"]'), + element.querySelector('[data-testid="message-agent-owner"]'), + element.querySelector(`[data-testid="${timestampId}"]`), + ]; + if (targets.some((target) => !target)) { + throw new Error("Expected a complete agent message header."); + } + + await new Promise((resolve, reject) => { + let previousSignature: string | null = null; + let stableFrameCount = 0; + let sampledFrameCount = 0; + + const sample = () => { + const signature = targets + .map((target) => { + const rect = target?.getBoundingClientRect(); + return rect + ? [rect.x, rect.y, rect.width, rect.height] + .map((value) => value.toFixed(3)) + .join(",") + : "missing"; + }) + .join("|"); + + stableFrameCount = + signature === previousSignature ? stableFrameCount + 1 : 0; + sampledFrameCount += 1; + previousSignature = signature; + + if (stableFrameCount >= 2) { + resolve(); + } else if (sampledFrameCount >= 120) { + reject(new Error("Agent message header layout did not stabilize.")); + } else { + requestAnimationFrame(sample); + } + }; + + requestAnimationFrame(sample); + }); + }, timestampTestId); +} + +async function measureAgentHeaderGeometry( + row: Locator, + timestampTestId: string, +) { + return row.evaluate((element, timestampId) => { + const author = element.querySelector( + '[data-testid="message-author"]', + ); + const ownerTreatment = element.querySelector( + '[data-testid="message-agent-owner"]', + ); + const managedBy = Array.from( + ownerTreatment?.querySelectorAll('[aria-hidden="true"]') ?? + [], + ).find((candidate) => candidate.textContent?.trim() === "managed by"); + const owner = ownerTreatment?.querySelector(".font-semibold"); + const icon = ownerTreatment?.querySelector("svg"); + const timestamp = element.querySelector( + `[data-testid="${timestampId}"]`, + ); + + if ( + !author || + !ownerTreatment || + !managedBy || + !owner || + !icon || + !timestamp + ) { + throw new Error("Expected measurable agent message header geometry."); + } + + const ownerStyle = getComputedStyle(ownerTreatment); + const timestampStyle = getComputedStyle(timestamp); + const ownerRect = ownerTreatment.getBoundingClientRect(); + const iconRect = icon.getBoundingClientRect(); + const iconCenterOffset = + iconRect.top + + iconRect.height / 2 - + (ownerRect.top + ownerRect.height / 2); + const baselineElements = [author, managedBy, owner, timestamp]; + const markers = baselineElements.map((baselineElement) => { + const marker = document.createElement("span"); + marker.style.cssText = + "display:inline-block;width:0;height:0;margin:0;padding:0;border:0"; + baselineElement.append(marker); + return marker; + }); + + try { + const [ + authorBaseline, + managedByBaseline, + ownerBaseline, + timestampBaseline, + ] = markers.map((marker) => marker.getBoundingClientRect().top); + + return { + baselines: { + author: authorBaseline, + managedBy: managedByBaseline, + owner: ownerBaseline, + timestamp: timestampBaseline, + }, + iconCenterOffset, + ownerTypography: { + fontSize: ownerStyle.fontSize, + lineHeight: ownerStyle.lineHeight, + }, + timestampTypography: { + fontSize: timestampStyle.fontSize, + lineHeight: timestampStyle.lineHeight, + }, + }; + } finally { + for (const marker of markers) marker.remove(); + } + }, timestampTestId); +} + +async function expectAlignedAgentHeader( + page: Page, + row: Locator, + timestampTestId: string, + surface: string, +) { + for (const fontSize of FONT_SIZES) { + await page.evaluate((value) => { + document.documentElement.dataset.fontSize = value; + }, fontSize); + await waitForStableAgentHeader(row, timestampTestId); + + const geometry = await measureAgentHeaderGeometry(row, timestampTestId); + for (const [label, baseline] of Object.entries(geometry.baselines)) { + expect( + Math.abs(baseline - geometry.baselines.author), + `${surface} ${label} baseline at ${fontSize} font size`, + ).toBeLessThanOrEqual(GEOMETRY_TOLERANCE_PX); + } + expect(geometry.ownerTypography).toEqual(geometry.timestampTypography); + expect( + Math.abs(geometry.iconCenterOffset), + `${surface} agent icon center at ${fontSize} font size`, + ).toBeLessThanOrEqual(GEOMETRY_TOLERANCE_PX); + } +} + +async function seedAgentInboxMessage(page: Page) { + await page.waitForFunction(() => { + const win = window as MockInboxWindow; + return ( + typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && + typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function" + ); }); + + return page.evaluate( + ({ channelId, currentPubkey, senderPubkey }) => { + const win = window as MockInboxWindow; + const emitMessage = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + const pushFeedItem = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__; + if (!emitMessage || !pushFeedItem) { + throw new Error("Mock Inbox helpers are unavailable."); + } + + const message = emitMessage({ + channelName: "general", + content: "Agent Inbox alignment check.", + id: "a9".repeat(32), + mentionPubkeys: [currentPubkey], + pubkey: senderPubkey, + }); + pushFeedItem({ + category: "mention", + channel_id: channelId, + channel_name: "general", + content: message.content, + created_at: message.created_at, + id: message.id, + kind: message.kind, + pubkey: message.pubkey, + tags: message.tags, + }); + return message.id; + }, + { + channelId: GENERAL_CHANNEL_ID, + currentPubkey: TEST_IDENTITIES.tyler.pubkey, + senderPubkey: TEST_IDENTITIES.alice.pubkey, + }, + ); } async function measureThreadSummaryGeometry(summaryRow: Locator) { @@ -390,7 +605,7 @@ test.beforeEach(async ({ page }, testInfo) => { await installMockBridge(page, mock); }); -test("agent owner label identifies the agent and owner", async ({ page }) => { +test("agent owner label aligns in the timeline and Inbox", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -409,63 +624,31 @@ test("agent owner label identifies the agent and owner", async ({ page }) => { "Agent managed by", ); - const author = aliceMessage.getByTestId("message-author"); - const managedBy = ownerTreatment.getByText("managed by", { exact: true }); - const owner = ownerTreatment.locator(".font-semibold"); - const timestamp = aliceMessage.getByTestId("message-timestamp"); + await expectAlignedAgentHeader( + page, + aliceMessage, + "message-timestamp", + "timeline", + ); - for (const fontSize of ["smaller", "default", "larger"]) { - await page.evaluate((value) => { - document.documentElement.dataset.fontSize = value; - }, fontSize); + await page.goto("/"); + await expect(page.getByTestId("home-inbox-list")).toBeVisible(); + const inboxMessageId = await seedAgentInboxMessage(page); - const [ - authorBaseline, - managedByBaseline, - ownerBaseline, - timestampBaseline, - ] = await Promise.all([ - measureInlineBaseline(author), - measureInlineBaseline(managedBy), - measureInlineBaseline(owner), - measureInlineBaseline(timestamp), - ]); - const [ownerTypography, timestampTypography] = await Promise.all([ - ownerTreatment.evaluate((element) => { - const style = getComputedStyle(element); - return { fontSize: style.fontSize, lineHeight: style.lineHeight }; - }), - timestamp.evaluate((element) => { - const style = getComputedStyle(element); - return { fontSize: style.fontSize, lineHeight: style.lineHeight }; - }), - ]); - const iconCenterOffset = await ownerTreatment.evaluate((element) => { - const icon = element.querySelector("svg"); - if (!icon) throw new Error("Expected the agent owner icon."); - const ownerRect = element.getBoundingClientRect(); - const iconRect = icon.getBoundingClientRect(); - return ( - iconRect.top + - iconRect.height / 2 - - (ownerRect.top + ownerRect.height / 2) - ); - }); - - expect( - { managedByBaseline, ownerBaseline, timestampBaseline }, - `message header baselines at ${fontSize} font size`, - ).toEqual({ - managedByBaseline: authorBaseline, - ownerBaseline: authorBaseline, - timestampBaseline: authorBaseline, - }); - expect(ownerTypography).toEqual(timestampTypography); - expect( - Math.abs(iconCenterOffset), - `agent icon center at ${fontSize} font size`, - ).toBeLessThan(0.01); - } + await page.getByTestId(`home-inbox-item-${inboxMessageId}`).click(); + const inboxMessage = page.getByTestId("home-inbox-selected-message"); + await expect(inboxMessage).toContainText("Agent Inbox alignment check."); + const inboxOwnerTreatment = inboxMessage.getByTestId("message-agent-owner"); + await expect( + inboxOwnerTreatment.getByText("managed by", { exact: true }), + ).toBeVisible(); + await expect(inboxOwnerTreatment.locator(".font-semibold")).toHaveText("bob"); + await expectAlignedAgentHeader( + page, + inboxMessage, + "inbox-message-timestamp", + "Inbox", + ); }); test("send a message and see it in timeline", async ({ page }) => {