Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
12 changes: 6 additions & 6 deletions e2e/config-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,17 +145,17 @@ test.describe('Configuration shell: search and deep links', () => {
await expect(page).toHaveURL(/tab=system/);
});

test('Ctrl+K on a custom endpoint name opens its card', async ({ page }) => {
test('Ctrl+K on a custom endpoint name opens its editor', async ({ page }) => {
await openTab(page, 'custom');
const first = page.locator('form [data-entry-card] button[aria-expanded]').first();
const name = (await first.locator('span[title]').first().getAttribute('title')) ?? '';
const first = page.locator('form li[data-endpoint-row]').first();
const name = (await first.getAttribute('data-endpoint-row')) ?? '';
test.skip(!name, 'No custom endpoints');
await openTab(page, 'system');
await search(page, name);
const card = page.locator('form [data-entry-card] button[aria-expanded="true"]', {
hasText: name,
const editor = page.getByRole('dialog');
await expect(editor.getByRole('heading', { name, exact: true })).toBeVisible({
timeout: 10_000,
});
await expect(card.first()).toBeVisible({ timeout: 10_000 });
});

test('a deep link to a nested field expands its ancestors', async ({ page }) => {
Expand Down
80 changes: 36 additions & 44 deletions e2e/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,64 +67,56 @@ test.describe('Configuration: Enter never edits another field', () => {
await page.getByRole('button', { name: 'Discard' }).click();
});

test('Enter in a create dialog still submits that dialog', async ({ page }) => {
test('Enter in the create endpoint form still submits it', async ({ page }) => {
await openConfigTab(page, 'custom');
await page
.getByRole('button', { name: /Create endpoint/ })
.first()
.click();
const dialog = page.getByRole('dialog');
await dialog.locator('#create-endpoint-name').fill('Enter Endpoint');
await dialog.locator('#create-endpoint-apiKey').fill('key');
const models = dialog.getByPlaceholder(/Add models/);
await page.getByRole('button', { name: 'Create endpoint' }).first().click();
const drawer = page.getByRole('dialog');
await drawer.getByRole('button', { name: /^Blank endpoint/ }).click();
await drawer.locator('#endpoints-custom-9999-name').fill('Enter Endpoint');
await drawer.locator('#endpoints-custom-9999-apiKey').fill('key');
const models = drawer.getByPlaceholder(/Add models/);
await models.fill('enter-model');
await models.press('Enter');
await expect(dialog).toHaveCount(1);
const baseURL = dialog.locator('#create-endpoint-baseURL');
await expect(drawer.getByRole('heading', { name: 'Create endpoint' })).toBeVisible();
const baseURL = drawer.locator('#endpoints-custom-9999-baseURL');
await baseURL.fill('https://enter.example.com/v1');
await baseURL.press('Enter');
await expect(dialog).toHaveCount(0);
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Enter Endpoint' }),
).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Done' }).click();
await expect(unsavedBar(page)).toHaveCount(1);
await page.getByRole('button', { name: 'Discard' }).click();
});
});

test.describe('Configuration: delete buttons', () => {
test('every delete button is announced as "Delete <item>"', async ({ page }) => {
test.describe('Configuration: deleting a custom endpoint', () => {
test('a librechat.yaml endpoint offers no delete; an added one is deleted with Undo (D1, D9)', async ({
page,
}) => {
await openConfigTab(page, 'custom');
const deleteButtons = page.locator('form button.icon-btn-danger');
test.skip((await deleteButtons.count()) === 0, 'No custom endpoints to delete');
await expect(page.locator('form button[aria-label="trash"]')).toHaveCount(0);
for (const label of await deleteButtons.evaluateAll((els) =>
els.map((el) => el.getAttribute('aria-label') ?? ''),
)) {
expect(label).toMatch(/^Delete \S/);
}
const results = await new AxeBuilder({ page })
const rows = page.locator('form li[data-endpoint-row]');
await expect(rows.first()).toBeVisible();
const names = await new AxeBuilder({ page })
.include('form')
.withRules(['button-name'])
.withRules(['button-name', 'list', 'listitem'])
.analyze();
expect(results.violations).toEqual([]);
});
expect(names.violations).toEqual([]);
const yamlRow = rows.filter({ hasText: 'librechat.yaml' }).first();
test.skip((await yamlRow.count()) === 0, 'No librechat.yaml endpoints');
await yamlRow.locator('button[aria-haspopup="dialog"]').click();
const drawer = page.getByRole('dialog');
await expect(drawer.getByRole('button', { name: 'Delete endpoint' })).toHaveCount(0);
await drawer.getByRole('button', { name: 'Done' }).click();

test('keyboard Enter on a card trash button deletes the entry without toggling the card', async ({
page,
}) => {
await openConfigTab(page, 'custom');
const headers = page.locator('form [data-entry-card] button[aria-expanded]');
const before = await headers.count();
test.skip(before === 0, 'No custom endpoints to delete');
const trashes = page.locator('form [data-entry-card] button[aria-label^="Delete "]');
test.skip(
(await trashes.count()) === 0,
'Every custom endpoint comes from librechat.yaml (D1)',
);
const trash = trashes.first();
await trash.focus();
await page.keyboard.press('Enter');
await expect(headers).toHaveCount(before - 1);
await expect(headers.first()).toHaveAttribute('aria-expanded', 'false');
const added = rows.filter({ hasText: 'Added in admin' }).first();
test.skip((await added.count()) === 0, 'Every custom endpoint comes from librechat.yaml (D1)');
const name = (await added.getAttribute('data-endpoint-row')) ?? '';
await added.locator('button[aria-haspopup="dialog"]').click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete endpoint' }).click();
await expect(page.getByText('Will be deleted when you save')).toBeVisible();
await expect(unsavedBar(page)).toHaveCount(1);
await page.getByRole('button', { name: 'Discard' }).click();
await page.getByRole('button', { name: `Undo deleting ${name}` }).click();
await expect(unsavedBar(page)).toHaveCount(0);
});
});
127 changes: 127 additions & 0 deletions e2e/providers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { test, expect } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { LIVE, LIVE_SKIP_REASON } from './live';

/**
* AI providers editor (WS9) journeys. They need a live LibreChat backend
* whose librechat.yaml defines an `endpoints.all` block that sets activity
* labels, Azure OpenAI groups, and no Anthropic model list: run with
* E2E_LIVE=1 against such a stack. Set E2E_DISABLED_PROVIDER to a provider
* the server does not enable (default `assistants`).
*/
test.skip(!LIVE, LIVE_SKIP_REASON);
test.describe.configure({ mode: 'serial', timeout: 120_000 });

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

async function openProviders(page: Page) {
await page.goto('/configuration?tab=providers');
await expect(page.locator('form[aria-label]')).toBeVisible({ timeout: 30_000 });
await page.waitForLoadState('networkidle');
}

/** The provider card, opened. */
async function provider(page: Page, key: string): Promise<Locator> {
const card = page.locator(`[id="section-endpoints.${key}"]`);
const trigger = card.locator('.settings-section-trigger').first();
if ((await trigger.getAttribute('aria-expanded')) !== 'true') await trigger.click();
return card;
}

/** A purpose group of a provider card, opened. */
async function group(card: Locator, providerKey: string, key: string): Promise<Locator> {
const section = card.locator(`[id="section-endpoints.${providerKey}-${key}"]`);
const trigger = section.locator('button[aria-expanded]').first();
if ((await trigger.getAttribute('aria-expanded')) !== 'true') await trigger.click();
return section;
}

async function reviewAndSave(page: Page) {
await page
.getByRole('region', { name: 'Unsaved changes' })
.getByRole('button', { name: /^Review & save$/ })
.click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: /^Save$/ }).click();
await expect(dialog).toHaveCount(0, { timeout: 30_000 });
}

test('Global overrides come first and the tab lists Network access', async ({ page }) => {
await openProviders(page);
const cards = page.locator('section[data-settings-section][id^="section-endpoints."]');
await expect(cards.first()).toHaveAttribute('id', 'section-endpoints.all');
await expect(cards.last()).toHaveAttribute('id', 'section-endpoints.allowedAddresses');
await expect(page.locator('[data-toc-id="section-endpoints.allowedAddresses"]')).toHaveText(
'Network access',
);
});

test('a provider the server does not enable says so in its header', async ({ page }) => {
await openProviders(page);
const header = page.locator(`[id="section-endpoints.${DISABLED_PROVIDER}"] .settings-heading`);
await expect(header.getByText('Not enabled')).toBeVisible();
});

test('a provider title model shows that Global overrides win (D11)', async ({ page }) => {
await openProviders(page);
const titles = await group(await provider(page, 'openAI'), 'openAI', 'titles');
await expect(titles.locator('.settings-banner')).toContainText(
'LibreChat reads title settings only from them',
);
const model = page.locator('#endpoints-openAI-titleModel');
await model.fill('gpt-e2e-title');
await model.press('Tab');
await expect(titles.getByText(/^Overridden by Global/).first()).toBeVisible();
await page.getByRole('button', { name: /^Discard$/ }).click();
});

test('Azure groups are read-only, with details in a drawer', async ({ page }) => {
await openProviders(page);
const card = await provider(page, 'azureOpenAI');
await expect(card.getByText(/Azure groups are managed in librechat.yaml/)).toBeVisible();
const details = card.getByRole('button', { name: /^Details of Azure group / }).first();
await details.click();
const drawer = page.getByRole('dialog');
await expect(drawer.getByText('Read-only. Change this group in librechat.yaml.')).toBeVisible();
await expect(drawer.getByRole('textbox')).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(drawer).toHaveCount(0);
await expect(details).toBeFocused();
});

test('the Anthropic model list never ends up empty', async ({ page }) => {
await openProviders(page);
const models = await group(await provider(page, 'anthropic'), 'anthropic', 'models');
await expect(models.getByRole('button', { name: 'Use provider defaults' })).toHaveAttribute(
'aria-pressed',
'true',
);
await models.getByRole('button', { name: 'Only these models' }).click();
const input = models.getByPlaceholder(/Add models/);
await input.fill('claude-e2e-model');
await input.press('Enter');
await reviewAndSave(page);
await expect(models.getByText('claude-e2e-model')).toBeVisible();

await models.getByRole('button', { name: 'Remove claude-e2e-model' }).click();
await expect(models.getByText(/default model list is used/)).toBeVisible();
await reviewAndSave(page);
await expect(models.getByRole('button', { name: 'Start from defaults' })).toBeVisible({
timeout: 30_000,
});
});

test('Network access checks each address as it is typed', async ({ page }) => {
await openProviders(page);
const card = await provider(page, 'allowedAddresses');
await card.getByRole('button', { name: /Add address/ }).click();
const input = card.getByPlaceholder('host:port, e.g. localhost:11434');
await input.fill('localhost');
await input.press('Enter');
await expect(card.getByRole('alert')).toHaveText('Add a port, for example localhost:11434.');
await input.fill('http://localhost:11434');
await input.press('Enter');
await expect(card.getByRole('alert')).toHaveText(
'Enter only host:port, without http:// or a path.',
);
});
Loading
Loading