Fix the CI failures on main (#2850)

* fix(app): keep an open workspace menu from unmounting its own trigger

The sidebar kebab is revealed by hover, and on a compact layout the menu it
opens is a sheet that slides up over the row it came from. The row un-hovers,
the trailing overlay unmounts the trigger, and the menu's open state goes with
it — nothing is left that can close the surface already presented in the
portal, so a full-screen backdrop swallows every click until reload.

Lift the menu's open state to the component that decides whether to render the
kebab, and keep the kebab rendered while the menu is up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(app): follow the sidebar and host badge redesigns in the e2e suite

Three specs were still driving surfaces that moved out from under them, so CI
has been red on main since the sidebar polish landed.

Display preferences became one row per decision with the options a page down,
so grouping and the host filter need that page walked first. Five specs had
their own copy of the two-line dance; it lives in the sidebar helper now, and
the host filter has its own opener next to the menu it belongs to.

The host badge is plain secondary text on the meta line and its identity colour
lives on the server icon alone, so the helpers stop asserting a tinted label
and a pill fill that the redesign removed.

Alignment rails are compared across canvas glyph metrics and SVG bounding
boxes, so the residual depends on the host's font; a 1px and a 0.5px tolerance
both landed on the wrong side of it in CI. Two pixels still catches the whole-
pixel offsets these rails encode. The compact sheet spec also read its rails
while the panel was still sliding in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(app): let a browser e2e run take a private Metro cache

Metro's on-disk cache is shared by every checkout on the machine. Editing one
app source file rebuilds that module against everyone else's cached output and
the Unistyles styles can come back desynced, which surfaces as alignment specs
failing with real-looking pixel numbers that go away when the edit is reverted.

E2E_METRO_CACHE_VERSION namespaces a run's cache without deleting anyone
else's entries, so the cache can be ruled out before a layout failure is
believed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(app): stop the chat scroll helper from hanging on a stalled sampler

`scrollChatAwayFromBottom` waited on a promise that could only ever resolve
from a `requestAnimationFrame` sampler started by a `wheel` listener. rAF does
not fire while the page is occluded, which is reachable on a CI runner, so the
sampler could stall on its first frame and the wait never returned — the test
died on its own 240s timeout with nothing to read. A missed wheel event did the
same.

Settling the scroll before measuring is an optimization; the assertion after it
is what decides the test. Sample on a timer with a deadline so a stall falls
through to that assertion instead of consuming the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(app): remove layout geometry e2e coverage

Rail and pixel-alignment assertions depend on host rendering details and should not define the browser E2E contract. Preserve the surrounding behavior coverage while dropping the Metro cache escape hatch that only supported diagnosing those assertions.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Ivan Malison
2026-08-04 10:29:52 +02:00
committed by GitHub
co-authored by Claude Opus 5 Mohamed Boudra
parent fb657ebc20
commit df32e6ddcd
21 changed files with 139 additions and 611 deletions
@@ -102,6 +102,8 @@ export async function waitForScrollableChat(
.toBeGreaterThan(input.minScrollableDistance);
}
const WHEEL_SETTLE_TIMEOUT_MS = 5_000;
export async function scrollChatAwayFromBottom(
page: Page,
input: { deltaY: number; minDistanceFromBottom: number },
@@ -111,31 +113,41 @@ export async function scrollChatAwayFromBottom(
if (!box) {
throw new Error("Agent chat scroll container is not visible");
}
const wheelSettled = scroll.evaluate(
(root: Element) =>
new Promise<void>((resolve) => {
root.addEventListener(
"wheel",
() => {
const scrollElement = root as HTMLElement;
let previousOffset = scrollElement.scrollTop;
let stableFrames = 0;
const sample = () => {
const currentOffset = scrollElement.scrollTop;
stableFrames = Math.abs(currentOffset - previousOffset) <= 0.5 ? stableFrames + 1 : 0;
previousOffset = currentOffset;
if (stableFrames >= 3) {
resolve();
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
},
{ once: true },
);
}),
);
// Letting the scroll animation stop before measuring is an optimization, so it must never be
// the thing that decides the test — the assertion below is. The old wait could not say that:
// it resolved only from a `requestAnimationFrame` sampler started by a `wheel` listener, and
// rAF does not fire at all while the page is occluded, which is reachable on a CI runner and
// stalls the sampler on its first frame. A missed wheel event did the same. Either way the
// test hung until its own timeout with nothing to read. A timed sampler with a deadline
// cannot: worst case it gives up and the poll below reports what the scroll actually did.
const wheelSettled = scroll.evaluate((root: Element, timeoutMs: number) => {
const scrollElement = root as HTMLElement;
return new Promise<void>((resolve) => {
const startedAt = Date.now();
let sawWheel = false;
let previousOffset = scrollElement.scrollTop;
let stableSamples = 0;
root.addEventListener(
"wheel",
() => {
sawWheel = true;
},
{ once: true },
);
const sample = () => {
const currentOffset = scrollElement.scrollTop;
const isStable = sawWheel && Math.abs(currentOffset - previousOffset) <= 0.5;
stableSamples = isStable ? stableSamples + 1 : 0;
previousOffset = currentOffset;
if (stableSamples >= 3 || Date.now() - startedAt >= timeoutMs) {
resolve();
return;
}
setTimeout(sample, 16);
};
sample();
});
}, WHEEL_SETTLE_TIMEOUT_MS);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, input.deltaY);
await wheelSettled;
@@ -125,26 +125,6 @@ export async function expectChatOutlinePromptToRemainBare(
await expect(chatOutlinePrompt(page, position)).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
}
export async function expectChatOutlineAlignedWithActiveTabGlyph(page: Page): Promise<void> {
const outlinePill = chatOutlinePrompt(page, 1).locator("div").first();
const activeTabGlyph = page
.getByTestId("workspace-tabs-row")
.filter({ visible: true })
.locator('[role="button"][aria-selected="true"]')
.locator("svg")
.first();
await expect
.poll(async () => {
const [pillBox, glyphBox] = await Promise.all([
requireBoundingBox(outlinePill),
requireBoundingBox(activeTabGlyph),
]);
return Math.abs(pillBox.x - glyphBox.x);
})
.toBeLessThanOrEqual(1);
}
/** Exactly one prompt is marked, without saying which — the reader always has a "you are here". */
export async function expectOneActiveChatOutlinePrompt(page: Page): Promise<void> {
await expect(chatOutlineRail(page).getByRole("tab", { selected: true })).toHaveCount(1);
+11 -36
View File
@@ -130,6 +130,13 @@ export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
});
}
// Host filters live a page below the display-preferences root, so the filter rows only exist
// after walking into that branch.
export async function openSidebarHostFilter(page: Page): Promise<void> {
await openSidebarDisplayPreferences(page);
await page.getByTestId("sidebar-display-host-filter").click();
}
// A host's filter row carries a status dot on the left next to its label.
export async function expectHostFilterRow(page: Page, serverId: string): Promise<void> {
await expect(page.getByTestId(`sidebar-host-filter-${serverId}`)).toBeVisible();
@@ -144,32 +151,6 @@ export async function selectAllHostsFilter(page: Page): Promise<void> {
await page.getByTestId("sidebar-host-filter-all").click();
}
// Playwright reports resolved colors, so the expected value is derived from the same identity
// table the app reads. A hex literal in the test would be a second copy of the palette.
function toRgb(hex: string): string {
const { r, g, b } = parseHex(hex);
return `rgb(${r}, ${g}, ${b})`;
}
function toRgba(hex: string, alpha: number): string {
const { r, g, b } = parseHex(hex);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
interface RgbChannels {
r: number;
g: number;
b: number;
}
function parseHex(hex: string): RgbChannels {
return {
r: Number.parseInt(hex.slice(1, 3), 16),
g: Number.parseInt(hex.slice(3, 5), 16),
b: Number.parseInt(hex.slice(5, 7), 16),
};
}
export async function openHostAppearanceSettings(page: Page, serverId: string): Promise<void> {
await openSettings(page);
await selectSettingsHost(page, serverId);
@@ -242,17 +223,16 @@ export async function expectNoHostBadge(page: Page, target: HostBadgeTarget): Pr
await expect(hostBadge(page, target)).toHaveCount(0, { timeout: 15_000 });
}
// The host sits on the meta line as plain secondary text, so its identity colour lives on the
// server icon alone. The label stays muted like every other item on that line — asserting a
// tinted label or a pill fill would re-assert the design the meta row replaced.
export async function expectHostBadgeTinted(
page: Page,
target: HostBadgeTarget & { color: IdentityColorName },
): Promise<void> {
const badge = hostBadge(page, target);
await expect(badge).toBeVisible({ timeout: 15_000 });
await expect(badge.getByText(target.hostName)).toHaveCSS(
"color",
toRgb(identityColor(target.color)),
);
await expect(badge).toHaveCSS("background-color", toRgba(identityColor(target.color), 0.1));
await expect(badge).toHaveText(target.hostName);
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(target.color));
}
@@ -264,11 +244,6 @@ export async function expectHostAppearancePreview(
await expect(preview).toBeVisible();
const badge = preview.getByTestId(`sidebar-host-badge-${input.serverId}`);
await expect(badge).toHaveText(input.hostName);
await expect(badge.getByText(input.hostName)).toHaveCSS(
"color",
toRgb(identityColor(input.color)),
);
await expect(badge).toHaveCSS("background-color", toRgba(identityColor(input.color), 0.1));
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(input.color));
}
@@ -1,9 +1,4 @@
import { expect, type Locator, type Page } from "@playwright/test";
export interface Rails {
left: number;
right: number;
}
import { expect, type Locator } from "@playwright/test";
interface Box {
x: number;
@@ -18,30 +13,6 @@ async function readBox(locator: Locator): Promise<Box> {
return box as Box;
}
/** Leading and trailing edges of an element, in whole pixels. */
export async function readRails(locator: Locator): Promise<Rails> {
const box = await readBox(locator);
return { left: Math.round(box.x), right: Math.round(box.x + box.width) };
}
/**
* The two rails an AdaptiveModalSheet header indents to: the title's leading
* glyph and the close button's trailing edge. Sheet content sits on the same
* two rails, in every presentation.
*/
export async function readSheetHeaderRails(
page: Page,
sheetTestID: string,
title: string,
): Promise<Rails> {
const header = page.getByTestId(sheetTestID);
const [titleRails, closeRails] = await Promise.all([
readRails(header.getByText(title, { exact: true })),
readRails(header.getByRole("button", { name: "Close" })),
]);
return { left: titleRails.left, right: closeRails.right };
}
/** Vertical space between the bottom of one element and the top of the next. */
export async function readVerticalGap(upper: Locator, lower: Locator): Promise<number> {
const [above, below] = await Promise.all([readBox(upper), readBox(lower)]);
@@ -166,6 +166,19 @@ export async function expectWorkspaceAbsentFromSidebar(
).toHaveCount(0, { timeout: 30_000 });
}
// The display-preferences menu is one row per decision, and the options sit a page below that
// row. Every caller has to walk the same path, so it lives here: when the menu's shape moves
// again, this is the only place that has to follow.
export async function openSidebarDisplayPage(page: Page, branchTestID: string): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId(branchTestID).click();
}
export async function selectSidebarStatusGrouping(page: Page): Promise<void> {
await openSidebarDisplayPage(page, "sidebar-display-grouping");
await page.getByTestId("sidebar-grouping-status").click();
}
export async function openMobileAgentSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu" }).click();
}