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
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);
});
});
93 changes: 70 additions & 23 deletions e2e/saves.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,19 @@ async function openCustomEndpoints(page: Page) {
await page.waitForLoadState('networkidle');
}

/** The expanded card of the custom endpoint named `name`. */
/** The editor drawer of the custom endpoint named `name`. */
async function card(page: Page, name: string): Promise<Locator> {
const entry = page.locator('[id^="section-endpoints-custom-"]').filter({
has: page.locator('span.truncate').getByText(name, { exact: true }),
});
await expect(entry).toHaveCount(1);
const header = entry.locator('button[aria-expanded]').first();
if ((await header.getAttribute('aria-expanded')) !== 'true') await header.click();
return entry;
const row = page.locator(`form li[data-endpoint-row="${name}"]`);
await expect(row).toHaveCount(1);
await row.locator('button[aria-haspopup="dialog"]').click();
const drawer = page.getByRole('dialog');
await expect(drawer.getByRole('heading', { name, exact: true })).toBeVisible();
return drawer;
}

async function closeEditor(page: Page) {
await page.getByRole('dialog').getByRole('button', { name: 'Done' }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
}

async function review(page: Page) {
Expand Down Expand Up @@ -54,10 +58,13 @@ test.describe('Configuration saves: custom endpoints by identity', () => {
page,
}) => {
await openCustomEndpoints(page);
const label = (await card(page, YAML_ENDPOINT)).getByLabel('Model display label');
const label = (await card(page, YAML_ENDPOINT)).getByRole('textbox', {
name: 'Model display label',
});
const original = await label.inputValue();
const edited = `E2E ${Date.now()}`;
await setText(label, edited);
await closeEditor(page);
const dialog = await review(page);
await expect(dialog).toContainText('Save 1 change to the base configuration?');
await expect(dialog).toContainText(YAML_ENDPOINT);
Expand All @@ -67,52 +74,92 @@ test.describe('Configuration saves: custom endpoints by identity', () => {

await page.reload();
await page.waitForLoadState('networkidle');
const reloaded = (await card(page, YAML_ENDPOINT)).getByLabel('Model display label');
const reloaded = (await card(page, YAML_ENDPOINT)).getByRole('textbox', {
name: 'Model display label',
});
await expect(reloaded).toHaveValue(edited);
await setText(reloaded, original);
await closeEditor(page);
await review(page);
await saveAndWait(page);
});

test('a librechat.yaml endpoint cannot be removed or renamed (D1)', async ({ page }) => {
await openCustomEndpoints(page);
await expect(page.locator(`button[aria-label="Delete ${YAML_ENDPOINT}"]`)).toHaveCount(0);

const yamlCard = await card(page, YAML_ENDPOINT);
await expect(yamlCard.getByRole('button', { name: 'Delete endpoint' })).toHaveCount(0);
await expect(yamlCard.getByRole('textbox', { name: /^Name/ })).toHaveCount(0);
await expect(yamlCard.locator('[data-static-value][id$="-name"]')).toHaveText(YAML_ENDPOINT);
});

test('an endpoint added in the panel can be renamed and deleted', async ({ page }) => {
const name = `E2E Endpoint ${Date.now()}`;
await openCustomEndpoints(page);
await page
.getByRole('button', { name: /Create endpoint/ })
.first()
.click();
await page.getByRole('button', { name: 'Create endpoint' }).first().click();
const create = page.getByRole('dialog');
await create.locator('#create-endpoint-name').fill(name);
await create.locator('#create-endpoint-apiKey').fill('user_provided');
await create.locator('#create-endpoint-baseURL').fill('https://e2e.example.com/v1');
await create.getByRole('button', { name: /^Blank endpoint/ }).click();
await create.locator('#endpoints-custom-9999-name').fill(name);
await create.locator('#endpoints-custom-9999-apiKey').fill('user_provided');
await create.locator('#endpoints-custom-9999-baseURL').fill('https://e2e.example.com/v1');
const model = create.getByLabel('Add models, comma-separated');
await model.fill('e2e-model');
await model.press('Enter');
await create.getByRole('button', { name: /^Create$/ }).click();
await create.getByRole('button', { name: 'Create endpoint', exact: true }).click();
await closeEditor(page);
await review(page);
await saveAndWait(page);

await setText((await card(page, name)).getByLabel(/^Name\s*\*?$/), `${name} 2`);
const editor = await card(page, name);
await setText(editor.getByLabel(/^Name\s*\*?$/), `${name} 2`);
await closeEditor(page);
const dialog = await review(page);
await expect(dialog).toContainText(`${name} 2`);
await saveAndWait(page);

await page.reload();
await page.waitForLoadState('networkidle');
await page.locator(`button[aria-label="Delete ${name} 2"]`).click();
const renamed = await card(page, `${name} 2`);
await renamed.getByRole('button', { name: 'Delete endpoint' }).click();
await review(page);
await saveAndWait(page);
await page.reload();
await page.waitForLoadState('networkidle');
await expect(page.locator(`button[aria-label="Delete ${name} 2"]`)).toHaveCount(0);
await expect(page.locator(`form li[data-endpoint-row="${name} 2"]`)).toHaveCount(0);
});

test('renaming an endpoint whose key is stored asks for the key again (CE-1)', async ({
page,
}) => {
const name = `E2E Keyed ${Date.now()}`;
await openCustomEndpoints(page);
await page.getByRole('button', { name: 'Create endpoint' }).first().click();
const create = page.getByRole('dialog');
await create.getByRole('button', { name: /^Blank endpoint/ }).click();
await create.locator('#endpoints-custom-9999-name').fill(name);
await create.locator('#endpoints-custom-9999-apiKey').fill('sk-e2e-literal-key-0000ABCD');
await create.locator('#endpoints-custom-9999-baseURL').fill('https://e2e.example.com/v1');
const model = create.getByLabel('Add models, comma-separated');
await model.fill('e2e-model');
await model.press('Enter');
await create.getByRole('button', { name: 'Create endpoint', exact: true }).click();
await closeEditor(page);
await review(page);
await saveAndWait(page);

const editor = await card(page, name);
await setText(editor.getByLabel(/^Name\s*\*?$/), `${name} 2`);
await expect(
editor.getByRole('alert').filter({ hasText: /Enter the API key again/ }),
).toBeVisible();
await closeEditor(page);
const blocked = await review(page);
await expect(blocked.getByRole('button', { name: /^Save/ })).toBeDisabled();
await blocked.getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Discard' }).click();

const cleanup = await card(page, name);
await cleanup.getByRole('button', { name: 'Delete endpoint' }).click();
await review(page);
await saveAndWait(page);
});
});
73 changes: 38 additions & 35 deletions src/components/configuration/ConfigPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
getKeyedCollection,
isConfigValueEqual,
} from '@/utils';
import { CollectionEditsContext } from '@/contexts/CollectionEditsContext';
import { ConfigErrorsProvider } from '@/contexts/ConfigErrorsContext';
import { buildTocEntries, findTocEntry, toJumpTargets } from './toc';
import { RevealTargetContext } from '@/contexts/RevealTargetContext';
Expand Down Expand Up @@ -571,41 +572,43 @@ export function ConfigPage({
{isReady && (
<ScopeActionsProvider value={scopeActions}>
<RevealTargetContext.Provider value={highlightField ?? null}>
<ConfigTabContent
sections={sectionsForActiveTab}
configValues={activeConfigValues}
editedValues={editedValues}
onFieldChange={edits.handleFieldChange}
onResetField={edits.handleResetField}
onDiscardField={edits.handleDiscardField}
previewMode={false}
previewScope={editingScope}
previewChangedPaths={layers.scopeChangedPaths}
resolvedValues={layers.scopeResolvedValues}
permissions={permissions}
showChangedOnly={false}
readOnly={!canEditActiveTab || scopeNotFound != null}
configuredPaths={layers.activeConfiguredPaths}
dbOverridePaths={layers.overridePaths}
listOverrideCounts={layers.listOverrideCounts}
touchedPaths={edits.touchedPaths}
pendingResets={edits.pendingResets}
sectionPermissions={sectionPermissions}
schemaDefaults={layers.schemaDefaults}
showConfiguredOnly={customizedOnly}
isEditingScope={isEditingScope}
baseRecordKeys={layers.baseRecordKeys}
onValidationError={notifyError}
editSessionId={edits.editSessionId}
unknownSettings={topLevelUnknown}
onShowAll={showAllSettings}
filterText={filterText}
onClearFilter={clearFilter}
visibleSectionIds={filterSectionIds}
filterPaths={filterPaths}
valueLayers={layers.valueLayers}
visibilityEdits={edits.visibilityEdits}
/>
<CollectionEditsContext.Provider value={edits.collectionEdits}>
<ConfigTabContent
sections={sectionsForActiveTab}
configValues={activeConfigValues}
editedValues={editedValues}
onFieldChange={edits.handleFieldChange}
onResetField={edits.handleResetField}
onDiscardField={edits.handleDiscardField}
previewMode={false}
previewScope={editingScope}
previewChangedPaths={layers.scopeChangedPaths}
resolvedValues={layers.scopeResolvedValues}
permissions={permissions}
showChangedOnly={false}
readOnly={!canEditActiveTab || scopeNotFound != null}
configuredPaths={layers.activeConfiguredPaths}
dbOverridePaths={layers.overridePaths}
listOverrideCounts={layers.listOverrideCounts}
touchedPaths={edits.touchedPaths}
pendingResets={edits.pendingResets}
sectionPermissions={sectionPermissions}
schemaDefaults={layers.schemaDefaults}
showConfiguredOnly={customizedOnly}
isEditingScope={isEditingScope}
baseRecordKeys={layers.baseRecordKeys}
onValidationError={notifyError}
editSessionId={edits.editSessionId}
unknownSettings={topLevelUnknown}
onShowAll={showAllSettings}
filterText={filterText}
onClearFilter={clearFilter}
visibleSectionIds={filterSectionIds}
filterPaths={filterPaths}
valueLayers={layers.valueLayers}
visibilityEdits={edits.visibilityEdits}
/>
</CollectionEditsContext.Provider>
</RevealTargetContext.Provider>
</ScopeActionsProvider>
)}
Expand Down
Loading
Loading