Skip to content
Closed
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
130 changes: 130 additions & 0 deletions e2e/fixtures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ import {
groupActivity,
type PartTool,
type ToolApprovalRecord,
Message,
MessageAction,
MessageActionMenu,
MessageActions,
MessageContent,
MessageFold,
MessageFooter,
} from "@corbits/react-ui";
import {
CommandPalette,
Expand Down Expand Up @@ -817,6 +824,121 @@ function ToolApprovalScenario() {
);
}

/** 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")} />
<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 @@ -855,4 +977,12 @@ export const scenarios: Record<string, () => ReactNode> = {
"prompt-input-toolbar": () => <PromptToolbarScenario />,
"panel-tabs": () => <PanelTabsScenario />,
"tool-approval": () => <ToolApprovalScenario />,
"message-fold": () => <MessageFoldScenario />,
"message-fold-settle": () => (
<MessageFoldConversationScenario policy="settle" />
),
"message-fold-stick": () => (
<MessageFoldConversationScenario policy="stick" />
),
"message-actions": () => <MessageActionsScenario />,
};
181 changes: 181 additions & 0 deletions e2e/message.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
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("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