LibreChat/e2e/specs/mock/sidebar.spec.ts
Danny Avila 186b738d2d
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
🪟 fix: Re-measure Sidebar Chat List on Width Change to Fix Date-Group Spacing (#13981)
* 🪟 fix: Re-measure sidebar chat list on width change to fix date-group spacing

When the sidebar is expanded from a collapsed reload, virtualized rows first
measure mid-animation at a narrow width, so date-group headers wrap and cache an
inflated height. CellMeasurerCache(fixedWidth) keys heights by row, not width, so
the stale height persists once full width is reached — leaving gaps under headers.

Invalidate the measurement cache and recompute row heights whenever the measured
list width changes. Adds a Playwright mock e2e (seeds backdated convos across date
groups via a new db helper) that fails without the fix and passes with it.

* 🧪 test: Harden sidebar e2e (runtime-env path, midnight-safe seed, convo isolation)

Addresses Codex review on PR #13981:
- db.ts honors E2E_RUNTIME_ENV_PATH when locating the runtime Mongo URI.
- Seed timestamps anchor on local noon so the Today group stays in-day near midnight.
- Clear the shared user's conversations before seeding so later date-group headers
  are not pushed below the virtualized viewport by other specs' leftover chats.
2026-06-26 13:43:03 -04:00

184 lines
7 KiB
TypeScript

import { randomUUID } from 'crypto';
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { getE2EUser } from '../../setup/user';
import { clearUserConversations, deleteConversations, seedConversations } from './db';
import type { SeedConvo } from './db';
/** Size of the virtualized chat list grid vs. its measured container. */
const sizes = (page: Page) =>
page.evaluate(() => {
const grid = document.querySelector<HTMLElement>('aside .ReactVirtualized__Grid');
const wrap = grid?.parentElement ?? null;
const gridRect = grid?.getBoundingClientRect();
const wrapRect = wrap?.getBoundingClientRect();
return {
grid: gridRect ? gridRect.width : -1,
wrap: wrapRect ? wrapRect.width : -1,
gridH: gridRect ? gridRect.height : -1,
wrapH: wrapRect ? wrapRect.height : -1,
};
});
/**
* Polls until the grid matches its container AND the size has stopped changing
* between samples — the sidebar expand/collapse animation runs for 300ms, and a
* tracking-only check can match mid-animation on slow CI machines.
*/
const settledSizes = async (page: Page) => {
let prev = await sizes(page);
for (let attempt = 0; attempt < 40; attempt++) {
await page.waitForTimeout(350);
const next = await sizes(page);
const tracked =
next.wrap > 0 &&
next.wrapH > 0 &&
Math.abs(next.grid - next.wrap) <= 1 &&
Math.abs(next.gridH - next.wrapH) <= 1;
const stable = Math.abs(next.grid - prev.grid) <= 1 && Math.abs(next.gridH - prev.gridH) <= 1;
if (tracked && stable) {
return next;
}
prev = next;
}
throw new Error(`Sidebar chat list never settled: ${JSON.stringify(prev)}`);
};
test.describe('sidebar chat list', () => {
test('chat list width tracks the sidebar through resize and collapse cycles', async ({
page,
}) => {
test.setTimeout(60000);
await page.goto('/c/new', { timeout: 10000 });
await expect(page.locator('aside .ReactVirtualized__Grid').first()).toBeVisible({
timeout: 20000,
});
const initial = await settledSizes(page);
const separator = page.locator('[role="separator"][aria-label="Resize sidebar"]');
const sepBox = await separator.boundingBox();
expect(sepBox).not.toBeNull();
const startX = (sepBox?.x ?? 0) + (sepBox?.width ?? 0) / 2;
const y = (sepBox?.y ?? 0) + (sepBox?.height ?? 0) / 2;
await page.mouse.move(startX, y);
await page.mouse.down();
for (let i = 1; i <= 5; i++) {
await page.mouse.move(startX + i * 20, y);
await page.waitForTimeout(50);
}
await page.mouse.up();
const widened = await settledSizes(page);
expect(widened.grid).toBeGreaterThan(initial.grid);
await page.locator('aside').getByTestId('close-sidebar-button').click();
await page.locator('aside').getByTestId('open-sidebar-button').click();
const reopened = await settledSizes(page);
expect(reopened.grid).toBeGreaterThan(initial.grid);
await page.setViewportSize({ width: 1280, height: 540 });
const shrunken = await settledSizes(page);
expect(shrunken.gridH).toBeLessThan(reopened.gridH);
});
});
/**
* Regression: expanding the sidebar from a collapsed reload first measured the
* virtualized conversation rows mid-animation (narrow width), so date-group headers
* ("Previous 7 days", ...) wrapped and cached oversized heights. With `fixedWidth`
* the cache never re-measured at full width, leaving a gap between each header's
* text and the row beneath it.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
const userEmail = getE2EUser().email;
/**
* The header `<h2>` is single-line; its row wrapper should hug it (just the small
* top margin). A stale wrapped measurement inflates the wrapper well past this.
*/
const MAX_HEADER_PADDING = 24;
const GROUPS = [
{ label: 'Today', offsetDays: 0 },
{ label: 'Previous 7 days', offsetDays: 3 },
{ label: 'Previous 30 days', offsetDays: 15 },
] as const;
function buildSeed(): SeedConvo[] {
// Anchor on local noon so the zero-day group stays inside "today" even when the
// spec runs right after midnight; second-level offsets keep ordering within a day.
const noon = new Date();
noon.setHours(12, 0, 0, 0);
const base = noon.getTime();
return GROUPS.flatMap((group, groupIndex) =>
[0, 1].map((n) => ({
conversationId: randomUUID(),
title: `E2E ${group.label} #${n}`,
updatedAt: new Date(base - group.offsetDays * DAY_MS - (groupIndex + n) * 1000),
})),
);
}
// The DateLabel <h2> exposes an aria-label ("Chats from {date}"), so its accessible
// name is the full phrase, not the visible group label.
const heading = (page: Page, label: string) =>
page.getByRole('heading', { name: `Chats from ${label}`, exact: true });
const headerRow = (page: Page, label: string) =>
page.getByTestId('convo-list-row').filter({ has: heading(page, label) });
test.describe('sidebar conversation grouping', () => {
let seeded: SeedConvo[] = [];
test.afterEach(async () => {
if (seeded.length) {
await deleteConversations(seeded.map((c) => c.conversationId));
seeded = [];
}
});
test('keeps date-group spacing tight after expanding from a collapsed reload', async ({
page,
}) => {
test.setTimeout(60000);
// Isolate from rows other specs leave on the shared user, which could otherwise
// push the later date-group headers below the virtualized viewport.
await clearUserConversations(userEmail);
seeded = buildSeed();
await seedConversations(userEmail, seeded);
// Default load is expanded: confirm the seeded conversations render at all.
await page.goto('/c/new', { timeout: 10000 });
await expect(page.getByTestId('convo-item').first()).toBeVisible({ timeout: 15000 });
// Force the collapsed start state, then reload so the list mounts collapsed.
await page.evaluate(() =>
localStorage.setItem('unifiedSidebarExpanded', JSON.stringify(false)),
);
await page.reload({ timeout: 10000 });
await expect(page.getByTestId('open-sidebar-button')).toBeVisible();
// Expand: rows first measure during the width animation — the regression window.
await page.getByTestId('open-sidebar-button').click();
await expect(page.getByTestId('close-sidebar-button')).toBeVisible();
await expect(page.getByTestId('convo-item').first()).toBeVisible({ timeout: 15000 });
// Each header row must hug its single-line text, not retain an inflated height.
for (const { label } of GROUPS) {
const row = headerRow(page, label);
await expect(row).toBeVisible({ timeout: 10000 });
const rowBox = await row.boundingBox();
const textBox = await heading(page, label).boundingBox();
expect(rowBox, `row "${label}" should have a bounding box`).not.toBeNull();
expect(textBox, `heading "${label}" should have a bounding box`).not.toBeNull();
const padding = rowBox!.height - textBox!.height;
expect(
padding,
`header "${label}" row (${rowBox!.height}px) should hug its text (${textBox!.height}px)`,
).toBeLessThan(MAX_HEADER_PADDING);
}
});
});