Files
MeshChatX/tests/e2e/messages-conversation-scroll.spec.js

267 lines
10 KiB
JavaScript

const { test, expect } = require("@playwright/test");
const {
prepareE2eSession,
seedE2eLongConversationThread,
seedE2eAltShortConversationThread,
getE2eLocalLxmfHash,
E2E_SCROLL_PEER_HASH,
} = require("./helpers");
async function waitForMessagesViewportReady(page) {
await page.waitForFunction(
() => {
const el = document.getElementById("messages");
return el != null && el.getAttribute("aria-busy") !== "true";
},
null,
{ timeout: 30000 }
);
}
async function scrollMetrics(page) {
await waitForMessagesViewportReady(page);
const loc = page.locator("#messages");
return loc.evaluate((el) => ({
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
}));
}
async function setMessagesToBottom(page) {
await page.locator("#messages").evaluate((el) => {
const inner = el.firstElementChild;
const reverse = inner && getComputedStyle(inner).flexDirection === "column-reverse";
if (reverse) {
el.scrollTop = 0;
return;
}
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
});
}
async function messagesDistanceFromBottom(page) {
return page.locator("#messages").evaluate((el) => {
const inner = el.firstElementChild;
const reverse = inner && getComputedStyle(inner).flexDirection === "column-reverse";
const max = Math.max(0, el.scrollHeight - el.clientHeight);
if (reverse) {
return el.scrollTop;
}
return max - el.scrollTop;
});
}
async function messagesNearBottom(page) {
return page.locator("#messages").evaluate((el) => {
const inner = el.firstElementChild;
const reverse = inner && getComputedStyle(inner).flexDirection === "column-reverse";
const max = Math.max(0, el.scrollHeight - el.clientHeight);
const eps = 12;
if (reverse) {
return el.scrollTop <= eps;
}
return max - el.scrollTop <= eps;
});
}
async function waitForMessagesOverflow(page) {
await page.waitForFunction(
() => {
const el = document.getElementById("messages");
if (!el) {
return false;
}
return el.scrollHeight > el.clientHeight + 100;
},
null,
{ timeout: 30000 }
);
}
test.describe("Messages conversation scroll", () => {
test.use({ viewport: { width: 1280, height: 360 } });
test.beforeAll(async ({ request }) => {
await prepareE2eSession(request);
await seedE2eLongConversationThread(request, { messageCount: 120 });
await seedE2eAltShortConversationThread(request, { messageCount: 12 });
});
test.beforeEach(async ({ request }) => {
await prepareE2eSession(request);
});
test("starts near bottom with a long thread", async ({ page }) => {
await page.goto("/#/messages");
await expect(page.getByText("Conversations", { exact: true }).first()).toBeVisible({ timeout: 25000 });
await page
.locator(".conversation-item")
.filter({ hasText: /E2E scroll seed/ })
.first()
.click();
await waitForMessagesViewportReady(page);
await expect(page.locator("#messages")).toBeVisible({ timeout: 25000 });
await expect(
page
.locator("#messages")
.getByText(/E2E scroll seed 119/)
.first()
).toBeVisible({
timeout: 25000,
});
await waitForMessagesOverflow(page);
const m = await scrollMetrics(page);
expect(m.scrollHeight).toBeGreaterThan(m.clientHeight + 80);
expect(await messagesNearBottom(page)).toBe(true);
});
test("does not jump to bottom when scrolled up and inbound arrives", async ({ page, request }) => {
const localHash = await getE2eLocalLxmfHash(request);
await page.goto("/#/messages");
await expect(page.getByText("Conversations", { exact: true }).first()).toBeVisible({ timeout: 25000 });
await page
.locator(".conversation-item")
.filter({ hasText: /E2E scroll seed/ })
.first()
.click();
await waitForMessagesViewportReady(page);
await expect(page.locator("#messages")).toBeVisible({ timeout: 25000 });
await waitForMessagesOverflow(page);
await page.locator("#messages").evaluate(() => {
const el = document.getElementById("messages");
if (!el) {
return;
}
const max = Math.max(0, el.scrollHeight - el.clientHeight);
el.scrollTop = Math.max(0, max - 200);
});
await page.waitForTimeout(300);
const before = await scrollMetrics(page);
expect(await messagesNearBottom(page)).toBe(false);
await page.evaluate(
({ peerHash, localHash: lh }) => {
const buf = new Uint8Array(16);
window.crypto.getRandomValues(buf);
const hash = Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
return import("/js/WebSocketConnection.js").then((mod) => {
mod.default.emit("message", {
data: JSON.stringify({
type: "lxmf.delivery",
lxmf_message: {
hash,
source_hash: peerHash,
destination_hash: lh,
content: "E2E synthetic inbound (scroll up)",
timestamp: Math.floor(Date.now() / 1000),
},
}),
});
});
},
{ peerHash: E2E_SCROLL_PEER_HASH, localHash }
);
await page.waitForTimeout(500);
expect(await messagesNearBottom(page)).toBe(false);
});
test("stays pinned to bottom when new inbound arrives while at bottom", async ({ page, request }) => {
const localHash = await getE2eLocalLxmfHash(request);
await page.goto("/#/messages");
await expect(page.getByText("Conversations", { exact: true }).first()).toBeVisible({ timeout: 25000 });
await page
.locator(".conversation-item")
.filter({ hasText: /E2E scroll seed/ })
.first()
.click();
await waitForMessagesViewportReady(page);
await expect(page.locator("#messages")).toBeVisible({ timeout: 25000 });
await waitForMessagesOverflow(page);
await setMessagesToBottom(page);
await page.waitForTimeout(200);
expect(await messagesNearBottom(page)).toBe(true);
await page.evaluate(
({ peerHash, localHash: lh }) => {
const buf = new Uint8Array(16);
window.crypto.getRandomValues(buf);
const hash = Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
return import("/js/WebSocketConnection.js").then((mod) => {
mod.default.emit("message", {
data: JSON.stringify({
type: "lxmf.delivery",
lxmf_message: {
hash,
source_hash: peerHash,
destination_hash: lh,
content: "E2E synthetic inbound (at bottom)",
timestamp: Math.floor(Date.now() / 1000),
},
}),
});
});
},
{ peerHash: E2E_SCROLL_PEER_HASH, localHash }
);
await page.waitForTimeout(600);
expect(await messagesNearBottom(page)).toBe(true);
});
test("stays near bottom when switching between long and short threads repeatedly", async ({ page }) => {
await page.goto("/#/messages");
await expect(page.getByText("Conversations", { exact: true }).first()).toBeVisible({ timeout: 25000 });
const longRow = page
.locator(".conversation-item")
.filter({ hasText: /E2E scroll seed/ })
.first();
const shortRow = page
.locator(".conversation-item")
.filter({ hasText: /E2E alt short/ })
.first();
for (let i = 0; i < 5; i++) {
await longRow.click();
await waitForMessagesViewportReady(page);
await expect(page.locator("#messages")).toBeVisible({ timeout: 25000 });
await waitForMessagesOverflow(page);
expect(await messagesNearBottom(page)).toBe(true);
await shortRow.click();
await waitForMessagesViewportReady(page);
await expect(page.locator("#messages")).toBeVisible({ timeout: 25000 });
expect(await messagesNearBottom(page)).toBe(true);
}
});
test("preserves scroll anchor when loading older messages from the top", async ({ page }) => {
await page.goto("/#/messages");
await expect(page.getByText("Conversations", { exact: true }).first()).toBeVisible({ timeout: 25000 });
await page
.locator(".conversation-item")
.filter({ hasText: /E2E scroll seed/ })
.first()
.click();
await waitForMessagesViewportReady(page);
await expect(page.locator("#messages")).toBeVisible({ timeout: 25000 });
await waitForMessagesOverflow(page);
const initial = await scrollMetrics(page);
expect(initial.scrollHeight).toBeGreaterThan(initial.clientHeight + 80);
await page.locator("#messages").hover();
for (let i = 0; i < 60; i++) {
await page.mouse.wheel(0, -500);
}
await page.waitForTimeout(2500);
const loaded = await scrollMetrics(page);
if (loaded.scrollHeight > initial.scrollHeight) {
expect(await messagesDistanceFromBottom(page)).toBeGreaterThan(8);
}
});
});