Merge remote-tracking branch 'origin/main'

This commit is contained in:
Mohamed Boudra
2026-08-04 13:13:22 +02:00
25 changed files with 201 additions and 619 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(`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();
}