Skip to content
74 changes: 74 additions & 0 deletions e2e/conversation-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Reads and drives the conversation scroll container the e2e scenarios render.
import { expect, type Page } from "@playwright/test";

export const container = (page: Page) =>
page.locator("[data-slot='conversation-content']");

export const view = (page: Page) =>
container(page).evaluate((element) => ({
scrollTop: element.scrollTop,
top: element.getBoundingClientRect().top,
distanceFromEnd:
element.scrollHeight - element.scrollTop - element.clientHeight,
}));

/** How far an item's top sits below the top of the view. */
export const offsetOf = async (page: Page, id: string) => {
const { top } = await view(page);
const itemTop = await page
.getByTestId(id)
.evaluate((element) => element.getBoundingClientRect().top);
return itemTop - top;
};

/** Waits for the view to stop moving (a glide or a wheel scroll to land). */
export const settled = async (page: Page) => {
let previous = Number.NaN;
await expect
.poll(
async () => {
const { scrollTop } = await view(page);
const still = scrollTop === previous;
previous = scrollTop;
return still;
},
{ intervals: [100] },
)
.toBe(true);
};

/** The reader's own scroll: a wheel over the conversation. */
export const wheel = async (page: Page, deltaY: number) => {
const box = await container(page).boundingBox();
if (box === null) throw new Error("expected the conversation on screen");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, deltaY);
await settled(page);
};

export const atEnd = async (page: Page) => {
await expect
.poll(async () => (await view(page)).distanceFromEnd)
.toBeLessThanOrEqual(1);
};

/** The id of the topmost item in view. */
export const firstVisible = (page: Page) =>
container(page).evaluate((element) => {
const top = element.getBoundingClientRect().top;
const items = element.querySelectorAll<HTMLElement>("[data-testid]");
for (const item of items) {
if (item.getBoundingClientRect().bottom > top)
return item.dataset.testid ?? "";
}
return "";
});

/** Two frames: one for resize callbacks, one for any correction to land. */
export const frames = (page: Page) =>
page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
),
);
80 changes: 11 additions & 69 deletions e2e/conversation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,15 @@
import { expect, test, type Page } from "@playwright/test";

const container = (page: Page) =>
page.locator("[data-slot='conversation-content']");

const view = (page: Page) =>
container(page).evaluate((element) => ({
scrollTop: element.scrollTop,
top: element.getBoundingClientRect().top,
distanceFromEnd:
element.scrollHeight - element.scrollTop - element.clientHeight,
}));

/** How far an item's top sits below the top of the view. */
const offsetOf = async (page: Page, id: string) => {
const { top } = await view(page);
const itemTop = await page
.getByTestId(id)
.evaluate((element) => element.getBoundingClientRect().top);
return itemTop - top;
};

/** Waits for the view to stop moving (a glide or a wheel scroll to land). */
const settled = async (page: Page) => {
let previous = Number.NaN;
await expect
.poll(
async () => {
const { scrollTop } = await view(page);
const still = scrollTop === previous;
previous = scrollTop;
return still;
},
{ intervals: [100] },
)
.toBe(true);
};

/** The reader's own scroll: a wheel over the conversation. */
const wheel = async (page: Page, deltaY: number) => {
const box = await container(page).boundingBox();
if (box === null) throw new Error("expected the conversation on screen");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, deltaY);
await settled(page);
};

const atEnd = async (page: Page) => {
await expect
.poll(async () => (await view(page)).distanceFromEnd)
.toBeLessThanOrEqual(1);
};

/** The id of the topmost item in view. */
const firstVisible = (page: Page) =>
container(page).evaluate((element) => {
const top = element.getBoundingClientRect().top;
const items = element.querySelectorAll<HTMLElement>("[data-testid]");
for (const item of items) {
if (item.getBoundingClientRect().bottom > top)
return item.dataset.testid ?? "";
}
return "";
});
import {
atEnd,
container,
firstVisible,
frames,
offsetOf,
settled,
view,
wheel,
} from "./conversation-helpers.js";

/** Opens a scenario once its history (which arrives after mount) is in. */
const open = async (page: Page, scenario: string) => {
Expand All @@ -74,13 +22,7 @@ const write = async (page: Page, times: number) => {
for (let count = 0; count < times; count += 1) {
await page.getByRole("button", { name: "Write" }).click();
}
// One frame for the resize callback, one for any correction to land.
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
),
);
await frames(page);
};

test.describe("settle", () => {
Expand Down
121 changes: 121 additions & 0 deletions e2e/fixtures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ import {
MessageContent,
MessageFold,
MessageFooter,
ConversationEarlier,
useHistoryWindow,
} from "@corbits/react-ui";
import {
CommandPalette,
Expand Down Expand Up @@ -1193,6 +1195,116 @@ function MessageActionsScenario() {
);
}

type HistoryRow = { readonly id: string; readonly lines: number };

/** Rows of fractional heights, so rounding shows up in the positions. */
function historyWindowRows(from: number, to: number): HistoryRow[] {
return Array.from({ length: to - from }, (_, index) => ({
id: `r${from + index}`,
lines: 1 + ((from + index) % 3),
}));
}

const HISTORY_PAGE = 10;

/** Renders the window; must sit inside the Conversation it follows. */
function HistoryRows({
rows,
hasEarlier,
onLoadEarlier,
distance,
}: {
rows: readonly HistoryRow[];
hasEarlier: boolean;
onLoadEarlier: () => Promise<void>;
distance: number | undefined;
}) {
const history = useHistoryWindow({
items: rows,
windowSize: 30,
step: HISTORY_PAGE,
hasEarlier,
onLoadEarlier,
});
return (
<>
<ConversationContent>
<ConversationEarlier
hasEarlier={history.hasEarlier}
loading={history.loading}
stalled={history.stalled}
onReachStart={history.reachStart}
distance={distance}
/>
{history.items.map((row) => (
<ConversationItem key={row.id} itemId={row.id} data-testid={row.id}>
<p style={{ height: row.lines * 23.5, margin: 0 }}>Row {row.id}</p>
</ConversationItem>
))}
</ConversationContent>
<ConversationScrollButton />
<div style={{ display: "flex", gap: 8 }}>
<Button onClick={history.reachStart}>Load earlier</Button>
<output aria-label="Shown">{history.items.length}</output>
</div>
</>
);
}

/**
* `local` rows are loaded; `remote` older ones come from a pretend server a
* page at a time, when "Deliver" is pressed. "Deliver nothing" ends a load
* with no rows.
*/
function HistoryWindowScenario({
local,
remote,
distance,
}: {
local: number;
remote: number;
distance?: number;
}) {
const [from, setFrom] = useState(remote);
const [to, setTo] = useState(remote + local);
const [requests, setRequests] = useState(0);
const [pending, setPending] = useState<(() => void) | null>(null);
const settle = (rows: number) => {
if (pending === null) return;
setFrom((current) => Math.max(0, current - rows));
setPending(null);
pending();
};
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: 400,
width: 600,
}}
>
<Conversation>
<HistoryRows
rows={historyWindowRows(from, to)}
hasEarlier={from > 0}
onLoadEarlier={() => {
setRequests((count) => count + 1);
return new Promise((resolve) => setPending(() => resolve));
}}
distance={distance}
/>
</Conversation>
<div style={{ display: "flex", gap: 8 }}>
<Button onClick={() => setTo((current) => current + 1)}>Append</Button>
<Button onClick={() => settle(HISTORY_PAGE)}>Deliver</Button>
<Button onClick={() => settle(0)}>Deliver nothing</Button>
<output aria-label="Requests">{requests}</output>
</div>
</div>
);
}

export const scenarios: Record<string, () => ReactNode> = {
smoke: () => <Button>Ready</Button>,
"approval-card": () => <Approval state="idle" />,
Expand Down Expand Up @@ -1245,4 +1357,13 @@ export const scenarios: Record<string, () => ReactNode> = {
<MessageFoldConversationScenario policy="stick" />
),
"message-actions": () => <MessageActionsScenario />,
"history-window": () => (
<HistoryWindowScenario local={100} remote={0} distance={0} />
),
"history-window-paged": () => (
<HistoryWindowScenario local={20} remote={30} distance={0} />
),
"history-window-reach": () => (
<HistoryWindowScenario local={200} remote={0} />
),
};
Loading
Loading