Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4cf7bed
🧭 feat: Purpose-Built AI Providers Editor (Wave 2)
danny-avila Oct 20, 2026
b35ef73
🔌 feat: Custom Endpoints List and Editor Drawer (Wave 2)
danny-avila Oct 20, 2026
6c84a38
🧩 feat: Model Specs Editor (Wave 2)
danny-avila Oct 20, 2026
995c1b3
🐛 fix: Keep Pending Edits Reachable When Showing Set Values Only
danny-avila Oct 22, 2026
0585188
Merge branch 'feat/config-w2-1-providers' into feat/config-w2-2-endpo…
danny-avila Oct 22, 2026
f2a3c5b
Merge branch 'feat/config-w2-2-endpoints' into feat/config-w2-3-specs
danny-avila Oct 22, 2026
b9d1d02
🔒 fix: Mask Secret Values in Collapsed Provider Summaries
danny-avila Oct 22, 2026
9aea2b8
Merge branch 'feat/config-w2-1-providers' into feat/config-w2-2-endpo…
danny-avila Oct 22, 2026
ecbfe5d
🐛 fix: Restore Undone Endpoint Deletions in Their Original Order
danny-avila Oct 22, 2026
e8e08ac
🐛 fix: Keep Pending Inherited Endpoint Overrides Listed Under Set Val…
danny-avila Oct 22, 2026
1030105
🐛 fix: Store Complete Built-In Parameter Definitions
danny-avila Oct 22, 2026
de1e539
🐛 fix: Insert Header Placeholders Into Header Values Only
danny-avila Oct 22, 2026
0f404a7
Merge branch 'feat/config-w2-2-endpoints' into feat/config-w2-3-specs
danny-avila Oct 22, 2026
3d5460b
🐛 fix: Keep Spec Order When One Save Adds and Removes Specs
danny-avila Oct 22, 2026
16abc8a
🐛 fix: Keep a New Spec's Required Fields Under Set Values Only
danny-avila Oct 22, 2026
c09f17f
🐛 fix: Block Saving Blank or Space-Padded Duplicate Spec Names
danny-avila Oct 22, 2026
3e04456
🐛 fix: Index Only the Prompt Fields a Spec Editor Shows
danny-avila Oct 22, 2026
ebdab4c
🐛 fix: Compare a Spec Rename With the Name It Was Loaded With
danny-avila Oct 22, 2026
5a1c35d
🐛 fix: Don't Inherit Spec Fields Through a Replaced Spec List
danny-avila Oct 22, 2026
e7d8998
🐛 fix: Clear the Agent ID When a Spec Leaves the Agents Endpoint
danny-avila Oct 22, 2026
1aca69b
🐛 fix: Keep Configured Azure Groups Visible When Showing Set Values Only
danny-avila Oct 22, 2026
eb3ff9a
Merge branch 'feat/config-w2-1-providers' into feat/config-w2-2-endpo…
danny-avila Oct 22, 2026
28238c1
Merge branch 'feat/config-w2-2-endpoints' into feat/config-w2-3-specs
danny-avila Oct 22, 2026
5888feb
🐛 fix: Resolve Swapped and Chained Entry Renames as One Batch
danny-avila Oct 22, 2026
1c9f458
🐛 fix: Keep Card State With a Spec When the List Is Reordered
danny-avila Oct 22, 2026
626b7ae
🐛 fix: Align the Spec Editor's Model, Parameter and Unknown Rows With…
danny-avila Oct 22, 2026
dede842
🎨 style: Format the Spec Editor's Parameter Imports
danny-avila Oct 22, 2026
55fa271
Merge main (previous level's squash 37d8db5) into feat/config-w2-3-specs
danny-avila Oct 23, 2026
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
207 changes: 207 additions & 0 deletions e2e/specs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { test, expect } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { LIVE, LIVE_SKIP_REASON } from './live';

/**
* Model specs journeys (WS11, the audit's specs journeys). They need a live
* LibreChat backend whose librechat.yaml defines at least one model spec and
* whose Base stores no spec list of its own (reset the admin overrides first):
* run with E2E_LIVE=1. Each journey that saves puts the list back to
* librechat.yaml before it ends.
*/
const unsavedBar = (page: Page) => page.getByRole('region', { name: 'Unsaved changes' });
const cards = (page: Page) => page.locator('#section-modelSpecs\\.list [data-entry-card]');

async function openSpecs(page: Page, search = '') {
await page.goto(`/configuration?tab=specs${search}`);
await page.waitForLoadState('networkidle');
await expect(page.getByRole('heading', { name: /^Specs/ })).toBeVisible();
}

async function expand(card: Locator) {
const header = card.locator('button[aria-expanded]').first();
if ((await header.getAttribute('aria-expanded')) !== 'true') await header.click();
}

async function review(page: Page) {
await unsavedBar(page)
.getByRole('button', { name: /^Review & save$/ })
.click();
return page.getByRole('dialog');
}

async function saveAndWait(page: Page) {
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: /^Save$/ }).click();
await expect(dialog).toHaveCount(0);
}

/** Puts the spec list back to librechat.yaml when the journey left an override. */
async function revertList(page: Page) {
await openSpecs(page);
const revert = page.getByRole('button', { name: 'Revert list to librechat.yaml' });
if ((await revert.count()) === 0) return;
await revert.click();
await review(page);
await saveAndWait(page);
}

/** Adds a spec with the four required fields, and returns its card. */
async function addSpec(page: Page, label: string, endpoint: string, model: string) {
await page.getByRole('button', { name: 'Add spec' }).click();
const card = cards(page).last();
await expect(card.getByLabel(/^Label\*?$/)).toBeFocused();
await card.getByLabel(/^Label\*?$/).fill(label);
await card.getByLabel(/^Label\*?$/).press('Tab');
await card.getByLabel(/^Endpoint\*?$/).selectOption(endpoint);
await card.getByRole('combobox', { name: /^Model\*?$/ }).fill(model);
await card.getByRole('combobox', { name: /^Model\*?$/ }).press('Tab');
return card;
}

test.describe('Model specs', () => {
test.skip(!LIVE, LIVE_SKIP_REASON);
test.describe.configure({ mode: 'serial', timeout: 120_000 });

test('the tab says how users pick models and what they will see', async ({ page }) => {
await openSpecs(page);
await expect(page.getByRole('group', { name: 'How users pick models' })).toBeVisible();
await expect(page.getByText('Users will see:')).toBeVisible();
await expect(cards(page).first()).toBeVisible();
await expect(page.getByText('List order is the order of the model menu.')).toBeVisible();
});

test('a new spec asks for Endpoint and Model up front and saves at the end of the list', async ({
page,
}) => {
await openSpecs(page);
const before = await cards(page).count();
const card = await addSpec(page, 'E2E Spec', 'openAI', 'gpt-4o-mini');
await expect(card.getByLabel(/^Name\*?$/)).toHaveValue('e2e-spec');
const dialog = await review(page);
await expect(dialog).toContainText('Added model spec');
await saveAndWait(page);
await page.reload();
await page.waitForLoadState('networkidle');
await expect(cards(page)).toHaveCount(before + 1);
await expect(cards(page).last()).toContainText('E2E Spec');
await expect(cards(page).last()).toContainText('openAI · gpt-4o-mini');
await revertList(page);
});

test('duplicate names and incomplete specs block Save', async ({ page }) => {
await openSpecs(page);
const first = cards(page).first();
await expand(first);
const name = await first.getByLabel(/^Name\*?$/).inputValue();
const card = await addSpec(page, 'Copy', 'openAI', 'gpt-4o');
await card.getByLabel(/^Name\*?$/).fill(name);
await card.getByLabel(/^Name\*?$/).press('Tab');
await expect(card.getByText('already uses this name')).toBeVisible();
const dialog = await review(page);
await expect(dialog.getByText(/must be fixed before saving/)).toBeVisible();
await expect(dialog.getByRole('button', { name: /^Save$/ })).toBeDisabled();
await dialog.getByRole('button', { name: 'Cancel' }).click();
await unsavedBar(page).getByRole('button', { name: 'Discard' }).click();
});

test('only one spec can be the default', async ({ page }) => {
await openSpecs(page);
await addSpec(page, 'E2E A', 'openAI', 'gpt-4o');
const b = await addSpec(page, 'E2E B', 'openAI', 'gpt-4o');
const a = cards(page).nth((await cards(page).count()) - 2);
for (const card of [a, b]) {
await expand(card);
await card.getByRole('button', { name: /^New chats/ }).click();
await card.getByRole('button', { name: 'Default', exact: true }).click();
}
const pressed = (card: Locator) =>
card.getByRole('button', { name: 'Default', exact: true }).getAttribute('aria-pressed');
expect(await pressed(a)).toBe('false');
expect(await pressed(b)).toBe('true');
await unsavedBar(page).getByRole('button', { name: 'Discard' }).click();
});

test('reordering is saved (list order is menu order)', async ({ page }) => {
await openSpecs(page);
await addSpec(page, 'E2E Last', 'openAI', 'gpt-4o');
await review(page);
await saveAndWait(page);
await page.reload();
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'Move E2E Last up' }).click();
const dialog = await review(page);
await expect(dialog).toContainText('Order changed');
await saveAndWait(page);
await page.reload();
await page.waitForLoadState('networkidle');
const count = await cards(page).count();
await expect(cards(page).nth(count - 2)).toContainText('E2E Last');
await revertList(page);
});

test('Enter in a spec field never adds or removes an endpoint next to the specs', async ({
page,
}) => {
await openSpecs(page);
const card = cards(page).first();
await expand(card);
await card.getByLabel(/^Label\*?$/).press('Enter');
await card.getByRole('combobox', { name: /^Model\*?$/ }).press('Enter');
await card.getByLabel('Model label', { exact: true }).press('Enter');
await expect(unsavedBar(page)).toHaveCount(0);
const picker = page.locator('#modelSpecs-addedEndpoints-add');
await expect(picker.locator('option[value=""]')).toHaveCount(0);
});

test('an Anthropic spec offers only Anthropic parameters', async ({ page }) => {
await openSpecs(page);
const card = await addSpec(page, 'E2E Claude', 'anthropic', 'claude-sonnet-4-5');
await card.getByRole('button', { name: /^Parameters/ }).click();
await expect(card.getByLabel('Top K', { exact: true })).toBeVisible();
await expect(card.getByLabel('Thinking budget', { exact: true })).toBeVisible();
await expect(card.getByLabel('Frequency penalty', { exact: true })).toHaveCount(0);
await expect(card.getByLabel('Reasoning effort', { exact: true })).toHaveCount(0);
await unsavedBar(page).getByRole('button', { name: 'Discard' }).click();
});

test('a deleted spec waits for Save with Undo', async ({ page }) => {
await openSpecs(page);
const before = await cards(page).count();
const title = (await cards(page).first().locator('span.truncate').first().innerText()).trim();
await page.getByRole('button', { name: `Delete ${title}` }).click();
await expect(page.getByRole('button', { name: new RegExp(`Undo.*${title}`) })).toBeFocused();
await page.getByRole('button', { name: new RegExp(`Undo.*${title}`) }).click();
await expect(cards(page)).toHaveCount(before);
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: /^Customize list for/ });
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();
});

test('the tab filter finds spec fields and phone width has no sideways scroll', async ({
page,
}) => {
await openSpecs(page);
await page.getByPlaceholder('Filter this tab…').fill('soft default');
await expect(page.getByText('Default for new chats').first()).toBeVisible();
await page.getByPlaceholder('Filter this tab…').fill('');
await page.setViewportSize({ width: 390, height: 900 });
await expand(cards(page).first());
const overflow = await page.evaluate(() => {
const el = document.querySelector<HTMLElement>('.scrollbar-gutter-stable');
return el ? el.scrollWidth - el.clientWidth : 0;
});
expect(overflow).toBeLessThanOrEqual(0);
});
});
91 changes: 90 additions & 1 deletion src/components/configuration/edits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ describe('identity-keyed collection ops (SV-1)', () => {
context({ collections }),
);
expect(ops).toEqual([
expect.objectContaining({ op: 'upsertEntry', key: 'AdminC', identity: 'AdminB' }),
{ op: 'removeEntry', path: 'endpoints.custom', key: 'AdminA', identity: 'AdminA' },
expect.objectContaining({ op: 'upsertEntry', key: 'AdminC', identity: 'AdminB' }),
]);
});

Expand Down Expand Up @@ -205,6 +205,95 @@ describe('identity-keyed collection ops (SV-1)', () => {
]);
});

it('sends a reordered spec list with every kept spec at its new index', () => {
const specList: t.ConfigRecord[] = [
{ name: 'a', label: 'A', preset: { endpoint: 'openAI', model: 'm' } },
{ name: 'b', label: 'B', preset: { endpoint: 'openAI', model: 'm' } },
{ name: 'c', label: 'C', preset: { endpoint: 'openAI', model: 'm' } },
];
const specRoot: t.ConfigRecord = { modelSpecs: { list: specList } };
const reordered = [specList[2], specList[0], specList[1]];
const collections = trackCollectionEdit({}, {}, 'modelSpecs.list', reordered, specRoot);
const { ops } = buildConfigChanges(
new Set(['modelSpecs.list']),
{ 'modelSpecs.list': reordered },
schemaPaths,
context({ root: specRoot, collections }),
);
expect(ops).toEqual([
expect.objectContaining({ op: 'upsertEntry', identity: 'c', index: 0 }),
expect.objectContaining({ op: 'upsertEntry', identity: 'a', index: 1 }),
expect.objectContaining({ op: 'upsertEntry', identity: 'b', index: 2 }),
]);
const plan = planConfigChanges(ops, { stored: {}, lower: specRoot, lowerKind: 'yaml' });
expect(plan.sets.map((set) => set.fieldPath)).toEqual(['modelSpecs.list']);
const saved = plan.sets[0].value as t.ConfigRecord[];
expect(saved.map((spec) => spec.name)).toEqual(['c', 'a', 'b']);
});

it('keeps the spec order when a save both removes and adds specs', () => {
const spec = (name: string): t.ConfigRecord => ({
name,
label: name.toUpperCase(),
preset: { endpoint: 'openAI', model: 'm' },
});
const specList = [spec('a'), spec('b'), spec('c')];
const specRoot: t.ConfigRecord = { modelSpecs: { list: specList } };
const pending = [specList[1], specList[2], spec('x')];
const collections = trackCollectionEdit({}, {}, 'modelSpecs.list', pending, specRoot);
const { ops } = buildConfigChanges(
new Set(['modelSpecs.list']),
{ 'modelSpecs.list': pending },
schemaPaths,
context({ root: specRoot, collections }),
);
const plan = planConfigChanges(ops, { stored: {}, lower: specRoot, lowerKind: 'yaml' });
expect(plan.issues).toEqual([]);
const saved = plan.sets[0].value as t.ConfigRecord[];
expect(saved.map((entry) => entry.name)).toEqual(['b', 'c', 'x']);
});

it('saves two specs whose names were swapped', () => {
const specList: t.ConfigRecord[] = [
{ name: 'a', label: 'A', preset: { endpoint: 'openAI', model: 'm' } },
{ name: 'b', label: 'B', preset: { endpoint: 'openAI', model: 'm' } },
];
const specRoot: t.ConfigRecord = { modelSpecs: { list: specList } };
const edited: t.FlatConfigMap = {
'modelSpecs.list.0': { ...specList[0], name: 'b' },
'modelSpecs.list.1': { ...specList[1], name: 'a' },
};
const collections = Object.entries(edited).reduce<t.CollectionEdits>(
(acc, [path, value]) => trackCollectionEdit(acc, {}, path, value, specRoot),
{},
);
const { ops } = buildConfigChanges(
new Set(Object.keys(edited)),
edited,
schemaPaths,
context({ root: specRoot, collections }),
);
const plan = planConfigChanges(ops, { stored: {}, lower: specRoot, lowerKind: 'yaml' });
expect(plan.issues).toEqual([]);
const saved = plan.sets[0].value as t.ConfigRecord[];
expect(saved.map((spec) => [spec.name, spec.label])).toEqual([
['b', 'A'],
['a', 'B'],
]);
});

it('sends no positions for a reordered custom endpoint list (merged by name)', () => {
const reordered = [endpoints[2], endpoints[0], endpoints[1]];
const collections = trackCollectionEdit({}, {}, 'endpoints.custom', reordered, root);
const { ops } = buildConfigChanges(
new Set(['endpoints.custom']),
{ 'endpoints.custom': reordered },
schemaPaths,
context({ collections }),
);
expect(ops).toEqual([]);
});

it('turns a reset of the whole collection into a reset op (revert list to librechat.yaml)', () => {
const values = { 'modelSpecs.list': undefined };
const { ops } = buildConfigChanges(
Expand Down
Loading
Loading