Skip to content
132 changes: 132 additions & 0 deletions e2e/fixtures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ import {
type ToolApprovalRecord,
ToolGroup,
type PartToolBatch,
Message,
MessageAction,
MessageActionMenu,
MessageActions,
MessageContent,
MessageFold,
MessageFooter,
} from "@corbits/react-ui";
import {
CommandPalette,
Expand Down Expand Up @@ -1069,6 +1076,123 @@ function ToolGroupApprovalScenario() {
);
}

/** A long answer: 40 paragraphs of fixed-height lines, past the fold. */
const FOLD_PARAGRAPHS = Array.from(
{ length: 40 },
(_, index) => `Paragraph ${index + 1} of a long answer.`,
);
const FOLD_LENGTH = 10_000;

function FoldedAnswer() {
return (
<Message from="agent" data-testid="long-answer">
<MessageFold length={FOLD_LENGTH}>
<MessageContent>
{FOLD_PARAGRAPHS.map((text) => (
<p key={text} style={{ height: 48, margin: 0 }}>
{text}
</p>
))}
</MessageContent>
</MessageFold>
</Message>
);
}

/** A long answer between other rows, in a plain scroll container. */
function MessageFoldScenario() {
return (
<div
data-testid="scroller"
style={{ height: 400, width: 600, overflowY: "auto" }}
>
<p style={{ height: 600, margin: 0 }}>Before</p>
<FoldedAnswer />
<p style={{ height: 600, margin: 0 }}>After</p>
</div>
);
}

/** The same answer inside a conversation, which anchors its own scrolling. */
function MessageFoldConversationScenario({
policy,
}: {
policy: ConversationScrollPolicy;
}) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: 400,
width: 600,
}}
>
<Conversation scrollPolicy={policy}>
<ConversationContent data-testid="scroller">
{historyRows("h", 10).map((row) => (
<ConversationItem key={row.id} itemId={row.id}>
<p style={{ height: row.lines * 24, margin: 0 }}>Row {row.id}</p>
</ConversationItem>
))}
<ConversationItem itemId="long">
<FoldedAnswer />
</ConversationItem>
{historyRows("t", 10).map((row) => (
<ConversationItem key={row.id} itemId={row.id}>
<p style={{ height: row.lines * 24, margin: 0 }}>Row {row.id}</p>
</ConversationItem>
))}
</ConversationContent>
</Conversation>
</div>
);
}

function ActionRow({ record }: { record: (event: string) => void }) {
return (
<MessageActions>
<MessageAction label="Copy" onClick={() => record("copy")} />
<MessageAction label="Good response" aria-pressed />
<MessageAction label="Bad response" aria-pressed={false} />
<MessageActionMenu>
<MessageAction label="Pin" onClick={() => record("pin")} />
<MessageAction label="Copy link" onClick={() => record("link")} />
<MessageAction label="Report" onClick={() => record("report")} />
</MessageActionMenu>
</MessageActions>
);
}

/** An action row at the top of the page, and one at the foot of a short
* scroll container where a menu opening downwards would be clipped. */
function MessageActionsScenario() {
const { log, record } = useEventLog();
return (
<div style={{ width: 480 }}>
<Message from="agent" data-testid="top">
<MessageContent text="An answer with actions." />
<MessageFooter>
<ActionRow record={record} />
</MessageFooter>
</Message>
<button type="button">After</button>
<div
data-testid="clipped"
style={{ height: 240, overflowY: "auto", marginTop: 24 }}
>
<div style={{ height: 200 }} />
<Message from="agent" data-testid="bottom">
<MessageFooter>
<ActionRow record={record} />
</MessageFooter>
</Message>
</div>
{log}
</div>
);
}

export const scenarios: Record<string, () => ReactNode> = {
smoke: () => <Button>Ready</Button>,
"approval-card": () => <Approval state="idle" />,
Expand Down Expand Up @@ -1113,4 +1237,12 @@ export const scenarios: Record<string, () => ReactNode> = {
"panel-tabs": () => <PanelTabsScenario />,
"tool-approval": () => <ToolApprovalScenario />,
"tool-group-approval": () => <ToolGroupApprovalScenario />,
"message-fold": () => <MessageFoldScenario />,
"message-fold-settle": () => (
<MessageFoldConversationScenario policy="settle" />
),
"message-fold-stick": () => (
<MessageFoldConversationScenario policy="stick" />
),
"message-actions": () => <MessageActionsScenario />,
};
196 changes: 196 additions & 0 deletions e2e/message.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { expect, test, type Locator, type Page } from "@playwright/test";

const scroller = (page: Page) => page.getByTestId("scroller");

const toggle = (page: Page) =>
page.locator("[data-slot='message-fold-toggle']");

/** How far the fold control sits below the top of the scroll container. */
const toggleOffset = async (page: Page) => {
const box = await scroller(page).evaluate(
(element) => element.getBoundingClientRect().top,
);
const top = await toggle(page).evaluate(
(element) => element.getBoundingClientRect().top,
);
return top - box;
};

/** Waits for the container to stop moving. */
const settled = async (page: Page) => {
let previous = Number.NaN;
await expect
.poll(
async () => {
const top = await scroller(page).evaluate(
(element) => element.scrollTop,
);
const still = top === previous;
previous = top;
return still;
},
{ intervals: [100] },
)
.toBe(true);
};

/** Scrolls as the reader would, with the wheel, to put `contentTop` (a
* position inside the answer) at the top of the view. */
const readInto = async (page: Page, contentTop: number) => {
const answerTop = await page
.getByTestId("long-answer")
.evaluate((element, scrollerTop) => {
const container = element.closest("[data-testid='scroller']");
if (container === null) throw new Error("expected the scroller");
return (
element.getBoundingClientRect().top -
container.getBoundingClientRect().top +
container.scrollTop -
scrollerTop
);
}, 0);
const current = await scroller(page).evaluate((element) => element.scrollTop);
const box = await scroller(page).boundingBox();
if (box === null) throw new Error("expected the scroller on screen");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, answerTop + contentTop - current);
await settled(page);
};

/** Opens the answer, reads into the middle of it, and folds it from there:
* the control must stay where it was on screen. */
const foldFromInside = async (page: Page) => {
await toggle(page).click();
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
await readInto(page, 900);
const before = await toggleOffset(page);
const height = await scroller(page).evaluate(
(element) => element.clientHeight,
);
// Open, the control rides the bottom of the view while the answer is read.
expect(before).toBeGreaterThan(height / 2);
expect(before).toBeLessThan(height);

await toggle(page).click();
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
await settled(page);
const after = await toggleOffset(page);
expect(Math.abs(after - before)).toBeLessThanOrEqual(1);
};

test("folding an answer from inside it keeps the reader's place", async ({
page,
}) => {
await page.goto("/message-fold");
await expect(toggle(page)).toHaveText(/Show full answer/);
await foldFromInside(page);
});

for (const policy of ["settle", "stick"]) {
test(`folding inside a conversation (${policy}) keeps the reader's place`, async ({
page,
}) => {
await page.goto(`/message-fold-${policy}`);
await expect(toggle(page)).toBeAttached();
await foldFromInside(page);
});
}

const row = (page: Page, id: string) => page.getByTestId(id);
const trigger = (scope: Locator) =>
scope.getByRole("button", { name: "More actions" });
const menu = (scope: Locator) =>
scope.getByRole("menu", { name: "More actions" });
const item = (scope: Locator, name: string) =>
scope.getByRole("menuitem", { name });

test("a pressed action differs from a hovered one by more than colour", async ({
page,
}) => {
await page.goto("/message-actions");
const top = row(page, "top");
const pressed = top.getByRole("button", { name: "Good response" });
const hovered = top.getByRole("button", { name: "Bad response" });
await hovered.hover();
const shadow = (button: Locator) =>
button.evaluate((element) => getComputedStyle(element).boxShadow);
// The pressed action carries an inset outline that hover does not draw.
expect(await shadow(pressed)).toContain("inset");
expect(await shadow(hovered)).not.toContain("inset");
});

test("the overflow menu works from the keyboard and returns focus", async ({
page,
}) => {
await page.goto("/message-actions");
const top = row(page, "top");
await trigger(top).focus();
await page.keyboard.press("Enter");
await expect(menu(top)).toBeVisible();
await expect(trigger(top)).toHaveAttribute("aria-expanded", "true");
await expect(item(top, "Pin")).toBeFocused();

await page.keyboard.press("ArrowDown");
await expect(item(top, "Copy link")).toBeFocused();
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await expect(item(top, "Pin")).toBeFocused();
await page.keyboard.press("ArrowUp");
await expect(item(top, "Report")).toBeFocused();
await page.keyboard.press("Home");
await expect(item(top, "Pin")).toBeFocused();
await page.keyboard.press("End");
await expect(item(top, "Report")).toBeFocused();

await page.keyboard.press("Escape");
await expect(menu(top)).toHaveCount(0);
await expect(trigger(top)).toBeFocused();
await expect(trigger(top)).toHaveAttribute("aria-expanded", "false");
});

test("ArrowUp on the trigger opens the menu on its last item", async ({
page,
}) => {
await page.goto("/message-actions");
const top = row(page, "top");
await trigger(top).focus();
await page.keyboard.press("ArrowUp");
await expect(item(top, "Report")).toBeFocused();
});

test("choosing an item runs it, closes the menu and refocuses the trigger", async ({
page,
}) => {
await page.goto("/message-actions");
const top = row(page, "top");
await trigger(top).focus();
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await expect(page.getByRole("status", { name: "Events" })).toHaveText("link");
await expect(menu(top)).toHaveCount(0);
await expect(trigger(top)).toBeFocused();
});

test("focus leaving the menu closes it", async ({ page }) => {
await page.goto("/message-actions");
const top = row(page, "top");
await trigger(top).click();
await expect(item(top, "Pin")).toBeFocused();
await page.keyboard.press("Tab");
await expect(menu(top)).toHaveCount(0);
});

test("a menu that would be clipped below opens above", async ({ page }) => {
await page.goto("/message-actions");
const bottom = row(page, "bottom");
await trigger(bottom).click();
const content = menu(bottom);
await expect(content).toBeVisible();
await expect(content).toHaveAttribute("data-side", "top");
const clip = await page.getByTestId("clipped").boundingBox();
const box = await content.boundingBox();
if (clip === null || box === null) throw new Error("expected both boxes");
expect(box.y).toBeGreaterThanOrEqual(clip.y);
expect(box.y + box.height).toBeLessThanOrEqual(clip.y + clip.height);
});
Loading
Loading