Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user