Skip to content
Merged
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
40 changes: 36 additions & 4 deletions e2e/a11y/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
LIVE,
THEMES,
WCAG_TAGS,
lockBase,
CONFIG_TABS,
openConfigTab,
lockUserProfile,
Expand All @@ -16,8 +17,10 @@ import {
/**
* Accessibility and visual-system guardrails for the configuration page (VS-12):
* axe on every tab at 1440 and 390 px in both themes, layout invariants of the
* settings grid, and screenshot baselines. Needs a live backend (see ../live.ts);
* refresh baselines with `--update-snapshots` after intended visual changes.
* settings grid, and screenshot baselines. Needs a live backend (see ../live.ts).
* The baselines show librechat.yaml values only: refresh them with
* `--update-snapshots` after intended visual changes, on a Base that stores no
* admin overrides.
*/
test.skip(!LIVE, LIVE_SKIP_REASON);

Expand Down Expand Up @@ -301,16 +304,45 @@ const VISUAL_VARIANTS = [
{ label: 'light-390', colorScheme: 'light', width: 390, height: 1100 },
] as const;

/**
* Opens a tab and checks that Base stores no admin overrides. An override draws
* its tab's override count into every baseline, the header's revert action into
* the 1440 px ones, and its chip and section count into its own tab's. Each
* journey that saves to Base holds the Base lock until it has reverted, and the
* baseline holds it until its screenshot is taken, so an override found here was
* left by a run that failed before reverting, or saved by a test that does not
* take the lock. It fails naming its tabs ("System: 1 admin override") before a
* screenshot is compared or written by `--update-snapshots`.
*/
async function openWithoutBaseOverrides(page: Page, tab: string) {
await openConfigTab(page, tab);
await expect(
page.getByRole('tab', { selected: true }).locator('.config-tab-count').first(),
).toBeVisible();
const overridden = await page
.getByRole('tab')
.filter({ has: page.locator('.config-tab-count-override') })
.evaluateAll((tabs: HTMLElement[]) =>
tabs.map((el) => {
const count = el.querySelector('.config-tab-count-override')?.getAttribute('title');
return `${el.innerText.split('\n')[0]}: ${count}`;
}),
);
expect(overridden, 'Tabs where Base stores admin overrides, which no baseline shows').toEqual([]);
}

for (const variant of VISUAL_VARIANTS) {
test.describe(`visual baseline: ${variant.label}`, () => {
test.use({
colorScheme: variant.colorScheme,
viewport: { width: variant.width, height: variant.height },
});
lockBase();
lockUserProfile();

for (const tab of VISUAL_TABS) {
test(`${tab}`, async ({ page }) => {
await openConfigTab(page, tab);
await openWithoutBaseOverrides(page, tab);
await hideDevtools(page);
await expect(page).toHaveScreenshot(`${tab}-${variant.label}.png`, {
animations: 'disabled',
Expand Down Expand Up @@ -509,7 +541,7 @@ test.describe('a role profile with an override of its own', () => {
*/
let saved: 'profile' | 'override' | null = null;

lockUserProfile(async (page) => {
lockUserProfile(async ({ page }) => {
const undo = saved;
saved = null;
if (undo == null) return;
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion e2e/config-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ test.describe('Configuration shell: Back from the USER role profile', () => {
/** Set once a test creates the USER role's profile, so cleanup never deletes one it found. */
let createdUserProfile = false;

lockUserProfile(async (page) => {
lockUserProfile(async ({ page }) => {
if (!createdUserProfile) return;
createdUserProfile = false;
await deleteUserRoleProfile(page);
Expand Down
3 changes: 2 additions & 1 deletion e2e/features.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { LIVE, LIVE_SKIP_REASON, openConfigTab } from './live';
import { LIVE, LIVE_SKIP_REASON, lockBase, openConfigTab } from './live';

/**
* Features tab journeys (WS13): the memory agent switch, allowlists, web
Expand Down Expand Up @@ -35,6 +35,7 @@ async function setText(field: Locator, value: string) {
test.describe('Features editors', () => {
test.skip(!LIVE, LIVE_SKIP_REASON);
test.describe.configure({ mode: 'serial', timeout: 120_000 });
lockBase();

test('the memory agent switches from a librechat.yaml ID to an inline model and back', async ({
page,
Expand Down
57 changes: 40 additions & 17 deletions e2e/import.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { LIVE, LIVE_SKIP_REASON, lockUserProfile } from './live';
import { LIVE, LIVE_SKIP_REASON, lockBase, lockUserProfile } from './live';

/**
* The YAML import wizard needs LibreChat's admin config API, which the mock
Expand Down Expand Up @@ -45,22 +45,45 @@ async function startBalanceIn(page: Page, scope?: string) {
return page.locator('#balance-startBalance');
}

test('imports a snippet into Base, previewing exactly its keys', async ({ page }) => {
const value = 100000 + (Date.now() % 100000);
await openWizard(
page,
`version: 1.3.3\nbalance:\n startBalance: ${value}\ninterface:\n prompts: false\n`,
);
await page.getByRole('radio', { name: /^Base configuration/ }).click();
const { dialog, apply } = await previewAndApply(page, /into Base configuration$/);
await expect(dialog.getByRole('checkbox')).toHaveCount(1);
await expect(dialog.getByText('Starting credits')).toBeVisible();
await expect(dialog.getByText(/Prompts: managed with role permissions/)).toBeVisible();
await apply.click();
await expect(
page.getByText(/Imported 1 value into the base configuration/).first(),
).toBeVisible();
await expect(await startBalanceIn(page)).toHaveValue(value.toLocaleString('en-US'));
/**
* Puts Base's Starting credits back to librechat.yaml when an import left an
* override there. Base otherwise keeps it after the run, and every Base
* override shows in the configuration page's visual baselines.
*/
async function revertBaseStartBalance(page: Page) {
await startBalanceIn(page);
const revert = page.getByRole('button', {
name: /^(Revert to .+|Remove override): Starting credits$/,
});
if ((await revert.count()) === 0) return;
await revert.click();
await page.getByRole('button', { name: /^Review & save$/ }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: /^Save$/ }).click();
await expect(dialog).toHaveCount(0);
await expect(revert).toHaveCount(0);
}

test.describe('the base configuration', () => {
lockBase(({ page }) => revertBaseStartBalance(page));

test('imports a snippet into Base, previewing exactly its keys', async ({ page }) => {
const value = 100000 + (Date.now() % 100000);
await openWizard(
page,
`version: 1.3.3\nbalance:\n startBalance: ${value}\ninterface:\n prompts: false\n`,
);
await page.getByRole('radio', { name: /^Base configuration/ }).click();
const { dialog, apply } = await previewAndApply(page, /into Base configuration$/);
await expect(dialog.getByRole('checkbox')).toHaveCount(1);
await expect(dialog.getByText('Starting credits')).toBeVisible();
await expect(dialog.getByText(/Prompts: managed with role permissions/)).toBeVisible();
await apply.click();
await expect(
page.getByText(/Imported 1 value into the base configuration/).first(),
).toBeVisible();
await expect(await startBalanceIn(page)).toHaveValue(value.toLocaleString('en-US'));
});
});

test.describe('the USER role profile', () => {
Expand Down
135 changes: 87 additions & 48 deletions e2e/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test, expect } from '@playwright/test';
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
import type { Page, TestInfo } from '@playwright/test';
import type { Browser, Page, TestInfo } from '@playwright/test';

/**
* Configuration specs need a LibreChat backend that serves the admin config
Expand Down Expand Up @@ -78,15 +78,12 @@ function lockKey(baseUrl: string): string {
}

const LOCK_KEY = lockKey(process.env.E2E_BASE_URL ?? 'http://localhost:3000');
const LOCK_DIR = join(tmpdir(), `librechat-admin-e2e-user-profile-${LOCK_KEY}`);
const LOCK_POLL_MS = 250;
/** Longer than a locked test holds the lock, unless its body and its cleanup both hang. */
const LOCK_STALE_MS = 5 * 60_000;
/** Past the stale age, so a waiter takes over a hung holder's lock before it gives up. */
const LOCK_WAIT_MS = LOCK_STALE_MS + 60_000;

let lockHeld = false;

function lockOwner(dir: string): number | undefined {
try {
const pid = Number(readFileSync(join(dir, 'owner'), 'utf8'));
Expand All @@ -106,9 +103,9 @@ function isAlive(pid: number): boolean {
}

/**
* A lock is stale when its owner has exited, is this worker (one test runs at a time per
* worker, so it leaked), or has held it past the stale age. A lock whose owner file is not
* written yet is judged by age alone.
* A lock is stale when its owner has exited, is this worker (a worker only waits for a lock it
* does not hold, so it leaked), or has held it past the stale age. A lock whose owner file is
* not written yet is judged by age alone.
*/
function isStale(dir: string): boolean {
const pid = lockOwner(dir);
Expand All @@ -124,11 +121,11 @@ function isStale(dir: string): boolean {
* Moves a stale lock aside and deletes it. When the move fails, another waiter got there
* first; when the lock moved aside is not stale, a waiter re-took it in between, so it goes back.
*/
function takeOver() {
const aside = `${LOCK_DIR}.stale-${process.pid}-${Date.now()}`;
function takeOver(dir: string) {
const aside = `${dir}.stale-${process.pid}-${Date.now()}`;
try {
renameSync(LOCK_DIR, aside);
if (!isStale(aside)) renameSync(aside, LOCK_DIR);
renameSync(dir, aside);
if (!isStale(aside)) renameSync(aside, dir);
} catch {
/* Another waiter moved it first, or took the lock while it was aside. */
} finally {
Expand All @@ -137,62 +134,104 @@ function takeOver() {
}

/** One attempt: `mkdir` is atomic and fails while another worker holds the lock. */
function tryLock(): boolean {
function tryLock(dir: string): boolean {
try {
mkdirSync(LOCK_DIR);
mkdirSync(dir);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
if (isStale(LOCK_DIR)) takeOver();
if (isStale(dir)) takeOver(dir);
return false;
}
writeFileSync(join(LOCK_DIR, 'owner'), String(process.pid));
writeFileSync(join(dir, 'owner'), String(process.pid));
return true;
}

function releaseLock() {
if (!lockHeld) return;
lockHeld = false;
process.off('exit', releaseLock);
if (lockOwner(LOCK_DIR) === process.pid) rmSync(LOCK_DIR, { recursive: true, force: true });
}
/** A lock on one piece of E2E_BASE_URL's backend, shared by every worker and spec file. */
function backendLock(name: string, label: string) {
const dir = join(tmpdir(), `librechat-admin-e2e-${name}-${LOCK_KEY}`);
let held = false;

function release() {
if (!held) return;
held = false;
process.off('exit', release);
if (lockOwner(dir) === process.pid) rmSync(dir, { recursive: true, force: true });
}

/** Waits for the lock without spending the test's own timeout on the wait. */
async function acquireLock(testInfo: TestInfo) {
const started = Date.now();
const timeout = testInfo.timeout;
if (timeout) testInfo.setTimeout(timeout + LOCK_WAIT_MS);
while (!tryLock()) {
if (Date.now() - started > LOCK_WAIT_MS) {
const holder = lockOwner(LOCK_DIR) ?? 'unknown';
throw new Error(
`Gave up after ${LOCK_WAIT_MS / 1000} s waiting for the USER role profile lock ` +
`${LOCK_DIR}, held by pid ${holder}`,
);
/** Waits for the lock without spending the test's own timeout on the wait. */
async function acquire(testInfo: TestInfo) {
const started = Date.now();
const timeout = testInfo.timeout;
if (timeout) testInfo.setTimeout(timeout + LOCK_WAIT_MS);
while (!tryLock(dir)) {
if (Date.now() - started > LOCK_WAIT_MS) {
const holder = lockOwner(dir) ?? 'unknown';
throw new Error(
`Gave up after ${LOCK_WAIT_MS / 1000} s waiting for the ${label} lock ` +
`${dir}, held by pid ${holder}`,
);
}
await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
}
await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
held = true;
process.on('exit', release);
if (timeout) testInfo.setTimeout(timeout + Date.now() - started);
}
lockHeld = true;
process.on('exit', releaseLock);
if (timeout) testInfo.setTimeout(timeout + Date.now() - started);

return { acquire, release };
}

/**
* Runs each test of the calling describe alone against E2E_BASE_URL's backend, across workers
* and spec files. Every test that creates, uses or deletes the USER role's profile calls it:
* there are only two built-in roles, so no test gets a profile of its own. `cleanup` runs
* before the lock is released, also after a failure.
*/
export function lockUserProfile(cleanup?: (page: Page) => Promise<void>) {
test.beforeEach(() => acquireLock(test.info()));
test.afterEach(async ({ page }) => {
const userProfileLock = backendLock('user-profile', 'USER role profile');
const baseLock = backendLock('base', 'Base configuration');

/** Puts back what a locked test changed, before the lock is released, also after a failure. */
type LockCleanup = (
fixtures: { page: Page; browser: Browser },
testInfo: TestInfo,
) => Promise<void>;

/** Holds `lock` through each test of the calling describe and its `cleanup`. */
function lockEach(lock: ReturnType<typeof backendLock>, cleanup?: LockCleanup) {
test.beforeEach(() => lock.acquire(test.info()));
test.afterEach(async ({ page, browser }, testInfo) => {
try {
await cleanup?.(page);
await cleanup?.({ page, browser }, testInfo);
} finally {
releaseLock();
lock.release();
}
});
}

/**
* Runs each test of the calling describe alone against E2E_BASE_URL's backend, across workers
* and spec files. Every test that creates, uses or deletes the USER role's profile calls it:
* there are only two built-in roles, so no test gets a profile of its own.
*/
export function lockUserProfile(cleanup?: LockCleanup) {
lockEach(userProfileLock, cleanup);
}

/**
* Keeps Base's admin overrides from changing under a configuration visual baseline. Every
* describe whose journeys save to Base calls it, so each journey holds the lock from before its
* first save until it, or its `cleanup`, has put Base back; each baseline holds it from its check
* that Base stores no override until its screenshot is taken. A describe that also locks the USER
* role's profile nests inside this one, so every worker takes the two locks in the same order.
*/
export function lockBase(cleanup?: LockCleanup) {
lockEach(baseLock, cleanup);
}

/** Holds the Base lock (see lockBase) around a hook that saves to Base outside a test. */
export async function withBaseLock(testInfo: TestInfo, run: () => Promise<void>) {
await baseLock.acquire(testInfo);
try {
await run();
} finally {
baseLock.release();
}
}

/** Opens the scope selector once its profile list has loaded. */
export async function openScopeSelector(page: Page) {
await page.getByRole('button', { name: /^Select scope/ }).click();
Expand Down
3 changes: 2 additions & 1 deletion e2e/mcp.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { LIVE, LIVE_SKIP_REASON } from './live';
import { LIVE, LIVE_SKIP_REASON, lockBase } from './live';

/**
* MCP server journeys (WS12) from the MCP audit. They need a real LibreChat
Expand Down Expand Up @@ -66,6 +66,7 @@ async function setText(field: Locator, value: string) {
test.describe('Configuration: MCP servers', () => {
test.skip(!LIVE, LIVE_SKIP_REASON);
test.describe.configure({ mode: 'serial', timeout: 120_000 });
lockBase();

test('turning Show in chat menu on and back to Default leaves nothing to save (MCP-6)', async ({
page,
Expand Down
3 changes: 2 additions & 1 deletion e2e/providers.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { LIVE, LIVE_SKIP_REASON } from './live';
import { LIVE, LIVE_SKIP_REASON, lockBase } from './live';

/**
* AI providers editor (WS9) journeys. They need a live LibreChat backend
Expand All @@ -11,6 +11,7 @@ import { LIVE, LIVE_SKIP_REASON } from './live';
*/
test.skip(!LIVE, LIVE_SKIP_REASON);
test.describe.configure({ mode: 'serial', timeout: 120_000 });
lockBase();

const DISABLED_PROVIDER = process.env.E2E_DISABLED_PROVIDER ?? 'assistants';

Expand Down
Loading
Loading