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
49 changes: 41 additions & 8 deletions e2e/config-shell.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, Route } from '@playwright/test';
import { LIVE, LIVE_SKIP_REASON } from './live';
import { LIVE, LIVE_SKIP_REASON, lockUserProfile } from './live';

/**
* Configuration page shell (WS5): loading and error routes, Expand all,
Expand Down Expand Up @@ -179,24 +179,55 @@ test.describe('Configuration shell: search and deep links', () => {
});
});

test.describe('Configuration shell: profile scope and guard', () => {
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;

/** Opens the scope selector once its profile list has loaded. */
async function openScopeSelector(page: Page) {
await page.getByRole('button', { name: /^Select scope/ }).click();
return page.getByRole('dialog');
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('option', { name: /^Base configuration/ })).toBeVisible();
return dialog;
}

/** Opens the USER role's profile; on a clean database, creating it opens it. */
async function selectUserRole(page: Page) {
const dialog = await openScopeSelector(page);
const user = dialog.getByText('USER', { exact: true });
if ((await user.count()) === 0) {
await dialog.getByRole('button', { name: 'Create' }).click();
await dialog.getByText('USER', { exact: true }).first().click();
const user = dialog.getByRole('option', { name: /^USER / });
if ((await user.count()) > 0) {
await user.click();
return;
}
await user.first().click();
await dialog.getByRole('button', { name: 'Create' }).click();
await dialog.getByRole('option', { name: 'USER', exact: true }).click();
createdUserProfile = true;
}

/**
* Deletes the USER role's profile unless something has saved overrides to it since.
* The confirm view lists no options and stays up until the delete succeeds, so the
* profile list coming back is what proves the delete finished.
*/
async function deleteUserProfile(page: Page) {
await openTab(page, 'system');
const dialog = await openScopeSelector(page);
const user = dialog.getByRole('option', { name: /^USER / });
if ((await user.filter({ hasText: 'No overrides' }).count()) === 0) return;
await dialog.getByRole('option', { name: 'Delete profile USER', exact: true }).click();
await dialog.getByRole('button', { name: 'Delete configuration' }).click();
await expect(dialog.getByRole('option', { name: /^Base configuration/ })).toBeVisible({
timeout: 10_000,
});
await expect(user).toHaveCount(0);
}

lockUserProfile(async (page) => {
if (!createdUserProfile) return;
createdUserProfile = false;
await deleteUserProfile(page);
});

test('Back after selecting a profile restores Base in the URL and the UI', async ({ page }) => {
await openTab(page, 'system');
await selectUserRole(page);
Expand All @@ -206,7 +237,9 @@ test.describe('Configuration shell: profile scope and guard', () => {
await expect(page).not.toHaveURL(/scope=/);
await expect(page.getByRole('button', { name: /^Select scope: Base/ })).toBeVisible();
});
});

test.describe('Configuration shell: profile scope and guard', () => {
test('a fresh visit opens Base, an unknown profile says so', async ({ page }) => {
await openTab(page, 'system');
await expect(page.getByRole('button', { name: /^Select scope: Base/ })).toBeVisible();
Expand Down
39 changes: 23 additions & 16 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 } from './live';
import { LIVE, LIVE_SKIP_REASON, lockUserProfile } from './live';

/**
* The YAML import wizard needs LibreChat's admin config API, which the mock
Expand Down Expand Up @@ -31,11 +31,12 @@ async function previewAndApply(page: Page, target: RegExp) {
/** Opens the USER role's profile, creating it on a clean database. */
async function ensureUserProfile(page: Page) {
await page.goto('/configuration?tab=system&scope=role:USER');
await page.waitForLoadState('networkidle');
const opened = page.getByRole('button', { name: /^Select scope: USER/ });
const create = page.getByRole('button', { name: 'Create profile' });
if ((await create.count()) === 0) return;
await expect(opened.or(create)).toBeVisible({ timeout: 30_000 });
if (await opened.isVisible()) return;
await create.click();
await expect(page.getByRole('button', { name: /^Select scope: USER/ })).toBeVisible();
await expect(opened).toBeVisible();
}

async function startBalanceIn(page: Page, scope?: string) {
Expand All @@ -62,18 +63,24 @@ test('imports a snippet into Base, previewing exactly its keys', async ({ page }
await expect(await startBalanceIn(page)).toHaveValue(value.toLocaleString('en-US'));
});

test('imports into an existing profile, comparing with its own values', async ({ page }) => {
const value = 200000 + (Date.now() % 100000);
await ensureUserProfile(page);
await openWizard(page, `balance:\n startBalance: ${value}\n`, 'role:USER');
await expect(page.getByRole('radio', { name: /This profile \(USER\)/ })).toHaveAttribute(
'aria-checked',
'true',
);
const { apply } = await previewAndApply(page, /into Role: USER$/);
await apply.click();
await expect(page.getByText(/Imported 1 value into Role: USER/).first()).toBeVisible();
await expect(await startBalanceIn(page, 'role:USER')).toHaveValue(value.toLocaleString('en-US'));
test.describe('the USER role profile', () => {
lockUserProfile();

test('imports into an existing profile, comparing with its own values', async ({ page }) => {
const value = 200000 + (Date.now() % 100000);
await ensureUserProfile(page);
await openWizard(page, `balance:\n startBalance: ${value}\n`, 'role:USER');
await expect(page.getByRole('radio', { name: /This profile \(USER\)/ })).toHaveAttribute(
'aria-checked',
'true',
);
const { apply } = await previewAndApply(page, /into Role: USER$/);
await apply.click();
await expect(page.getByText(/Imported 1 value into Role: USER/).first()).toBeVisible();
await expect(await startBalanceIn(page, 'role:USER')).toHaveValue(
value.toLocaleString('en-US'),
);
});
});

test('imports into a role that has no profile yet', async ({ page }) => {
Expand Down
133 changes: 131 additions & 2 deletions e2e/live.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
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';

/**
* Configuration specs need a LibreChat backend that serves the admin config
Expand Down Expand Up @@ -58,6 +61,132 @@ export async function openConfigTab(page: Page, tab: string) {
await page.waitForLoadState('networkidle');
}

const LOOPBACK_HOSTS: ReadonlySet<string> = new Set(['127.0.0.1', '[::1]']);

/** One key per panel, however E2E_BASE_URL spells its loopback host or trailing slash. */
function lockKey(baseUrl: string): string {
const url = new URL(baseUrl);
const host = LOOPBACK_HOSTS.has(url.hostname) ? 'localhost' : url.hostname;
const path = url.pathname.replace(/\/+$/, '');
return `${host}-${url.port}${path}`.replace(/[^a-z0-9]+/gi, '-');
}

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'));
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
} catch {
return undefined;
}
}

function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM';
}
}

/**
* 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.
*/
function isStale(dir: string): boolean {
const pid = lockOwner(dir);
if (pid != null && (pid === process.pid || !isAlive(pid))) return true;
try {
return Date.now() - statSync(dir).mtimeMs > LOCK_STALE_MS;
} catch {
return false;
}
}

/**
* 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()}`;
try {
renameSync(LOCK_DIR, aside);
if (!isStale(aside)) renameSync(aside, LOCK_DIR);
} catch {
/* Another waiter moved it first, or took the lock while it was aside. */
} finally {
rmSync(aside, { recursive: true, force: true });
}
}

/** One attempt: `mkdir` is atomic and fails while another worker holds the lock. */
function tryLock(): boolean {
try {
mkdirSync(LOCK_DIR);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
if (isStale(LOCK_DIR)) takeOver();
return false;
}
writeFileSync(join(LOCK_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 });
}

/** 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}`,
);
}
await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
}
lockHeld = true;
process.on('exit', releaseLock);
if (timeout) testInfo.setTimeout(timeout + Date.now() - started);
}

/**
* 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 }) => {
try {
await cleanup?.(page);
} finally {
releaseLock();
}
});
}

/** click-ui Selects for the a11y specs: each `open` loads the Select's page and returns its trigger. */
export const SELECTS = [
{
Expand Down
6 changes: 3 additions & 3 deletions e2e/profiles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ test.describe('Configuration: profile lifecycle', () => {
await expect(page.getByRole('option', { name: new RegExp(`^${name}`) })).toBeVisible();
await page.keyboard.press('Escape');

await expect(page.locator('#section-registration [role=note]')).toContainText(
'applies to everyone',
);
await expect(
page.locator('#section-registration [role=note][data-read-only=scope]'),
).toContainText('applies to everyone');
await expect(page.locator('#section-registration input:not([disabled])')).toHaveCount(0);

const startBalance = page.locator('#balance-startBalance');
Expand Down
32 changes: 18 additions & 14 deletions e2e/specs.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, lockUserProfile } from './live';

/**
* Model specs journeys (WS11, the audit's specs journeys). They need a live
Expand Down Expand Up @@ -176,19 +176,23 @@ test.describe('Model specs', () => {
await expect(unsavedBar(page)).toHaveCount(0);
});

test('a profile shows the specs it inherits read-only until it customizes the list', async ({
page,
}) => {
await openSpecs(page, '&scope=role:USER');
const customize = page.getByRole('button', { name: /^Override for Role: USER/ });
test.skip((await customize.count()) === 0, 'Needs a USER role profile without its own list');
await expect(page.getByRole('button', { name: 'Add spec' })).toHaveCount(0);
await expect(cards(page).first()).toBeVisible();
await customize.click();
await expect(page.getByRole('button', { name: 'Add spec' })).toBeVisible();
await expect(page.getByText(/replaces Base's list/)).toBeVisible();
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByRole('button', { name: 'Add spec' })).toHaveCount(0);
test.describe('the USER role profile', () => {
lockUserProfile();

test('a profile shows the specs it inherits read-only until it customizes the list', async ({
page,
}) => {
await openSpecs(page, '&scope=role:USER');
const customize = page.getByRole('button', { name: /^Override for Role: USER/ });
test.skip((await customize.count()) === 0, 'Needs a USER role profile without its own list');
await expect(page.getByRole('button', { name: 'Add spec' })).toHaveCount(0);
await expect(cards(page).first()).toBeVisible();
await customize.click();
await expect(page.getByRole('button', { name: 'Add spec' })).toBeVisible();
await expect(page.getByText(/replaces Base's list/)).toBeVisible();
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByRole('button', { name: 'Add spec' })).toHaveCount(0);
});
});

test('the tab filter finds spec fields and phone width has no sideways scroll', async ({
Expand Down
Loading