diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index 23dbb5e5f4e..9e95a8dc244 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -435,9 +435,11 @@ test("reacting with a custom emoji renders via the loopback media proxy", async ); const inlineAddReactionButton = row.getByLabel("Add reaction"); - // The picker closes with the pointer/focus position depending on animation - // timing. Put the row into a deterministic idle state before checking the - // pill's pre-existing hidden behavior. + // Radix returns focus to "Open reactions" a task after the picker's exit + // animation unmounts it, which keeps the row focus-within. Wait for that + // before blurring, then put the row into a deterministic idle state before + // checking the pill's pre-existing hidden behavior. + await expect(messageReactionTrigger(row)).toBeFocused(); await page.mouse.move(0, 0); await page.evaluate(() => { if (document.activeElement instanceof HTMLElement) { diff --git a/desktop/tests/e2e/huddle-transcription.spec.ts b/desktop/tests/e2e/huddle-transcription.spec.ts index 622392989eb..9ce06e98450 100644 --- a/desktop/tests/e2e/huddle-transcription.spec.ts +++ b/desktop/tests/e2e/huddle-transcription.spec.ts @@ -692,6 +692,9 @@ test("animates the responding agent with the shared speaker ring", async ({ /rounded-squircle-clip/, ); + // The huddle chat's composer takes focus once its editor mounts, even from + // a focused button. Let that land before focusing the trigger. + await expect(page.getByTestId("message-input")).toBeFocused(); const voiceMenuTrigger = page.getByTestId("huddle-agent-voice-menu-trigger"); await voiceMenuTrigger.focus(); await expect(voiceMenuTrigger).toBeFocused(); diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index 59a8338071e..130abf48c1f 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -596,65 +596,117 @@ test("composer Buzz chip labels wrap without orphaning their icons", async ({ await expect(chip).toHaveText("relaytoolsobservabilityconsole-main"); await page.getByTestId("send-message").click(); - const sentChip = page.getByTestId("message-row").last().getByRole("button", { + const sentRow = page.getByTestId("message-row").last(); + const sentChip = sentRow.getByRole("button", { name: "Open repository relaytoolsobservabilityconsole-main", }); await expect(sentChip).toBeVisible(); await expect(sentChip).toHaveClass(/wrapping-inline-chip/); - await sentChip.evaluate((element) => { - const container = element.parentElement; - if (container) container.style.width = "220px"; - }); - const fragmentMetrics = await sentChip.evaluate((element) => { - const chipStyle = getComputedStyle(element); - const rects = Array.from(element.getClientRects(), (rect) => ({ - bottom: rect.bottom, - height: rect.height, - left: rect.left, - right: rect.right, - top: rect.top, - width: rect.width, - })).filter((rect) => rect.width > 0 && rect.height > 0); - const fragmentTops = Array.from( - new Set(rects.map((rect) => Math.round(rect.top))), - ).sort((a, b) => a - b); - return { - boxDecorationBreak: - chipStyle.getPropertyValue("box-decoration-break") || - chipStyle.getPropertyValue("-webkit-box-decoration-break"), - fragmentStep: - fragmentTops.length > 1 ? fragmentTops[1] - fragmentTops[0] : null, - lineHeight: Number.parseFloat(chipStyle.lineHeight), - rects, - }; - }); - expect(fragmentMetrics.boxDecorationBreak).toBe("clone"); - expect(fragmentMetrics.lineHeight).toBe(22); - expect(fragmentMetrics.fragmentStep).toBe(22); - expect(fragmentMetrics.rects.length).toBeGreaterThanOrEqual(2); + // The optimistic message row can be re-created when the relay echo lands, + // dropping any inline style applied before the swap — apply the width and + // measure in one evaluate, polling until the chip settles into wrapped + // fragments with the expected cloned-box metrics. + await expect + .poll(() => + sentChip.evaluate((element) => { + const container = element.parentElement; + if (container) container.style.width = "220px"; + const chipStyle = getComputedStyle(element); + const rects = Array.from(element.getClientRects()).filter( + (rect) => rect.width > 0 && rect.height > 0, + ); + const fragmentTops = Array.from( + new Set(rects.map((rect) => Math.round(rect.top))), + ).sort((a, b) => a - b); + return { + boxDecorationBreak: + chipStyle.getPropertyValue("box-decoration-break") || + chipStyle.getPropertyValue("-webkit-box-decoration-break"), + fragmentStep: + fragmentTops.length > 1 ? fragmentTops[1] - fragmentTops[0] : null, + lineHeight: Number.parseFloat(chipStyle.lineHeight), + wrapped: rects.length >= 2, + }; + }), + ) + .toMatchObject({ + boxDecorationBreak: "clone", + fragmentStep: 22, + lineHeight: 22, + wrapped: true, + }); + + // The send and the wrap settle the timeline at its floor over several + // frames, and Radix closes an open tooltip whenever an ancestor of its + // trigger scrolls. That settle scrolls only when layout changes, and it ends + // at the floor. So focus the chip once the timeline sits at its floor and + // holds there for two frames: a timeline that hasn't started settling yet + // isn't at the floor. The send's ack is one more layout change: it drops + // the row's "Sending…" status, and the confirmed row can lose its header. + // Wait for it first, so the floor the poll sees is the final one. + await expect(sentRow.getByTestId("message-send-status")).toHaveCount(0); + const timeline = page.getByTestId("message-timeline"); + await expect + .poll(() => + timeline.evaluate( + (element) => + new Promise((resolve) => { + const atFloor = () => + element.scrollHeight - element.clientHeight - element.scrollTop <= + 1; + if (!atFloor()) { + resolve(false); + return; + } + const scrollTop = element.scrollTop; + requestAnimationFrame(() => + requestAnimationFrame(() => + resolve(atFloor() && element.scrollTop === scrollTop), + ), + ); + }), + ), + ) + .toBe(true); const tooltip = page.getByRole("tooltip"); await sentChip.focus(); - await expect(tooltip).toBeVisible(); const positionOverFragment = async (index: number) => { - const fragment = await sentChip.evaluate((element, fragmentIndex) => { - const rects = Array.from(element.getClientRects()).filter( - (rect) => rect.width > 0 && rect.height > 0, - ); - const rect = rects.at(fragmentIndex); - if (!rect) return null; - return { - height: rect.height, - left: rect.left, - top: rect.top, - width: rect.width, - }; - }, index); - if (!fragment) throw new Error(`Expected chip fragment ${index}`); - const cursorX = fragment.left + fragment.width / 2; - const cursorY = fragment.top + fragment.height / 2; + // Virtua sets pointer-events: none on the timeline's rows while the list + // scrolls and clears it 150 ms after the last scroll event, longer than + // the floor wait's two frames. A move in that window misses the chip, and + // nothing sends it a pointermove once the rows take pointer events again, + // so the tooltip stays where focus opened it. Move only once the + // fragment's centre hit-tests to the chip. + let cursorX = Number.NaN; + let cursorY = Number.NaN; + await expect + .poll(async () => { + const fragment = await sentChip.evaluate((element, fragmentIndex) => { + const container = element.parentElement; + if (container) container.style.width = "220px"; + const rects = Array.from(element.getClientRects()).filter( + (rect) => rect.width > 0 && rect.height > 0, + ); + const rect = rects.at(fragmentIndex); + if (!rect) return null; + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + return { + hitsChip: element.contains(document.elementFromPoint(x, y)), + x, + y, + }; + }, index); + if (!fragment) throw new Error(`Expected chip fragment ${index}`); + cursorX = fragment.x; + cursorY = fragment.y; + return fragment.hitsChip; + }) + .toBe(true); await page.mouse.move(cursorX, cursorY); - await expect(tooltip).toBeVisible(); + // A closing tooltip stays visible through its exit animation. + await expect(tooltip).toHaveAttribute("data-state", /open/); let tooltipCenter = Number.NaN; await expect .poll(async () => { diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index 19332522fdd..613a46a9cf0 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -1343,6 +1343,33 @@ test("fast middle-page scroll settles with continuous mounted coverage", async ( ) .toBeGreaterThan(scrollHeightBeforePrepend + 2_000); + // Opening the channel armed the bottom settle, and only reader input + // cancels it, not the scrollTop writes below. Without a real wheel first, + // Virtua's next re-measure pins the list back to the floor. Wait until the + // wheel has left the floor (the timeline's 32 px at-bottom band) and held + // still for two frames, so the writes below are the last scroll. + await timeline.hover(); + await page.mouse.wheel(0, -120); + await expect + .poll(() => + timeline.evaluate( + (element) => + new Promise((resolve) => { + const scrollTop = element.scrollTop; + requestAnimationFrame(() => + requestAnimationFrame(() => + resolve( + element.scrollTop === scrollTop + ? element.scrollHeight - element.clientHeight - scrollTop + : 0, + ), + ), + ); + }), + ), + ) + .toBeGreaterThan(32); + // Simulate a fast trackpad pass through several middle-page ranges, then // stop. The final evaluate emits the last scroll event; all coverage samples // after it are passive observations.