mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 20:32:58 +00:00
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 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.
184 lines
7 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|