Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions desktop/tests/e2e/custom-emoji.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions desktop/tests/e2e/huddle-transcription.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
152 changes: 102 additions & 50 deletions desktop/tests/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>((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 () => {
Expand Down
27 changes: 27 additions & 0 deletions desktop/tests/e2e/scroll-history.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>((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.
Expand Down
Loading