LibreChat/e2e/specs/mock/agents.spec.ts
Marco Beretta 9de3249e9c
🎛️ feat: Redesign Settings with Registry-Driven Dialog, Search, and Mobile Drill-In (#13722)
* i18n: add settings reorganization keys

* feat(settings): add tab/section types and tab metadata

* feat(settings): add useSettingsContext guard hook

* feat(settings): add pure settings search filter with tests

* feat(settings): extract selectors and add control wrappers

* feat(settings): add setting registry, memory and billing controls, integrity test

* feat(settings): add Section and Advanced disclosure with test

* feat(settings): add content pane with tab and search views

* feat(settings): add sidebar and dialog shell with tests

* refactor(settings): wire new dialog and remove superseded containers

* fix(settings): restore speech external engine option, escape-to-clear search, results a11y

- SpeechControls.tsx: read sttExternal/ttsExternal from useGetCustomConfigSpeechQuery
  instead of hardcoding false, so external engine options appear on qualifying deployments
- Sidebar: Escape clears search input when non-empty, stops propagation to avoid closing dialog
- Content: persistent aria-live="polite" wrapper covers both populated results and empty state
- context: useMemo on returned ctx object so Content's useMemo deps are referentially stable
- locales/README.md: update stale path from deleted General.tsx to Selectors.tsx

* refactor(settings): reorganize categories, remove advanced disclosure, add About

- Re-categorize settings into logical groups (username display -> Chat/Messages,
  keep-screen-awake -> Accessibility, fork/prompts surfaced into Chat sections)
- Dissolve thin Personalization tab; move Memory into Data & Privacy
- Remove the Advanced collapsible; all settings always visible, destructive
  actions grouped in an always-visible Danger zone
- Wire the new About tab into the registry-driven dialog
- Standardize spacing with bordered, evenly-divided section cards
- Use semantic text-text-* / border tokens so dark mode renders correctly
- Sync LangSelector language-loading indicator from dev

* feat(settings): move archived chats to the account menu

Add an Archived chats item to the account dropdown next to My Files,
opening the archived chats table in a modal. Removes it from the
settings dialog where it no longer fit the data/privacy grouping.

* feat(settings): polish About panel and use shared CopyButton

- Flatten the build-info into a single divided key/value list (drop the
  redundant inner card now that it sits inside a section card)
- Replace the hand-rolled copy button with the shared animated CopyButton
- Shorten the copied label so it fits the button without clipping

* fix(settings): set primary text color on setting rows for dark mode

Leaf control labels rendered without a text color and fell back to the
browser default (black), making them invisible on the dark panel. Set
text-text-primary on the section and search-results row containers so
labels inherit a visible color, matching the old container behavior.

* fix(settings): use visible icon for dialog close button

The plain multiplication-sign close button had no text color and was
invisible on the dark panel. Replace it with the lucide X icon using
text-text-secondary/hover:text-text-primary so it shows in both themes.

* fix(nav): drop focus ring on account menu items, use hover background only

The account-settings popover drew a 2px ring around the active menu item.
Remove that override so items show only the standard hover background,
consistent with every other menu.

* fix(settings): replace native search clear with a real X button

The settings search used type=search, whose native WebKit clear control
rendered as a blue X. Switch to a text input and add a real lucide X
clear button styled text-text-secondary, shown only when there's a query.

* fix(speech): disable dependent dropdowns and switches when STT/TTS is off

Add a disabled prop to the shared Dropdown component, then gate the
speech engine/voice/language dropdowns and the automatic-playback switch
on their parent toggle (speechToText / textToSpeech), matching the
controls that already disabled correctly.

* feat(settings): mobile drill-in navigation for settings tabs

On small screens the horizontal scrolling tab row is replaced with a
full-width vertical list (with chevrons); tapping a tab drills into its
content with a Back header. Searching shows results full-width. Desktop
keeps the side-by-side sidebar + content layout unchanged.

* chore(settings): remove orphaned i18n keys, fix import order and review notes

- Drop the i18n keys left unused after the refactor (old Commands/Balance/
  Personalization tab labels, the Speech simple/advanced labels, and the
  former About section headings)
- Sort imports in the rebased files the lint-staged hook never touched
- Guard the language fallback against an empty navigator.languages
- Import the RefObject type instead of leaning on the React namespace

* feat(settings): searchable language dropdown

Add an opt-in searchable mode to the shared Dropdown (Ariakit Select +
Combobox) and use it for the language selector, which has 40+ options.
The trigger styling is unchanged so it stays consistent with the other
settings rows; only the popover gains a filter input.

Accessibility: the filtered listbox is labeled, the empty state is moved
out of the listbox and announced via an aria-live status region, and the
decorative selected-state checkmark is hidden from assistive tech.

* fix(settings): restore guards dropped in dialog refactor

- Fall back to the General tab when the active tab becomes hidden
  (e.g. About when buildInfo is disabled) instead of rendering an
  empty panel.
- Normalize a deprecated/invalid engineTTS (e.g. 'edge') back to
  browser during speech init so read-aloud controls keep rendering.
- Hide the cloud browser voices toggle unless Browser TTS is active.

* test(e2e): match agent-creation toast exactly to avoid SR-announce collision

The agent builder spec asserted the creation toast with a non-exact
getByText, which also matched Radix Toast's transient role="status"
announce region ("Notification Successfully created ..."), causing a
strict-mode violation. Mirror the mcp spec by using { exact: true }.

* fix(settings): render the active panel as a tabpanel

Wrap the non-search settings body in Tabs.Content so the selected
panel gets role=tabpanel with Radix's id/aria-labelledby wiring,
resolving the aria-controls target on each tab trigger. Search
results stay a labeled live region (the tab list is hidden during
mobile search, so a tabpanel aria-labelledby would dangle).
2026-06-18 08:51:07 -04:00

197 lines
7.7 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import type { AgentDetail } from './agents.helpers';
import {
cleanupAgent,
openAgentBuilder,
selectMockModel,
uniqueAgentName,
waitForPersistedAgent,
} from './agents.helpers';
import { MOCK_ENDPOINTS, mockReply, sendMessage } from './helpers';
const DESCRIPTION = 'Use this agent to verify LibreChat agent creation in mock end-to-end tests.';
const INSTRUCTIONS =
'Reply through the mock e2e model and keep this agent available for UI persistence checks.';
const MODEL_PARAMETERS = {
maxContextTokens: 32000,
maxOutputTokens: 4096,
temperature: 0.25,
topP: 0.8,
topK: 12,
resendFiles: false,
promptCache: true,
thinking: true,
thinkingBudget: 2000,
web_search: true,
fileTokenLimit: 12000,
};
const PERSISTED_MODEL_PARAMETERS = {
maxContextTokens: MODEL_PARAMETERS.maxContextTokens,
maxOutputTokens: MODEL_PARAMETERS.maxOutputTokens,
temperature: MODEL_PARAMETERS.temperature,
topP: MODEL_PARAMETERS.topP,
topK: MODEL_PARAMETERS.topK,
resendFiles: MODEL_PARAMETERS.resendFiles,
web_search: MODEL_PARAMETERS.web_search,
fileTokenLimit: MODEL_PARAMETERS.fileTokenLimit,
};
async function setSwitch(form: Locator, name: string, checked: boolean) {
const control = form.getByRole('switch', { name, exact: true });
await expect(control).toBeVisible();
if ((await control.getAttribute('aria-checked')) !== String(checked)) {
await control.click();
}
await expect(control).toHaveAttribute('aria-checked', String(checked));
}
async function fillAnthropicStyleModelParameters(page: Page) {
const form = page.getByRole('form', { name: 'Agent configuration form' });
await expect(form.getByLabel('Max Context Tokens')).toBeVisible();
await expect(form.getByLabel('Max Output Tokens')).toBeVisible();
await expect(form.getByText('Temperature', { exact: true })).toBeVisible();
await expect(form.getByText('Top P', { exact: true })).toBeVisible();
await expect(form.getByText('Top K', { exact: true })).toBeVisible();
await expect(form.getByText('Effort', { exact: true })).toBeVisible();
await expect(form.getByText('Thought Visibility', { exact: true })).toBeVisible();
await form
.locator('#maxContextTokens-dynamic-input')
.fill(`${MODEL_PARAMETERS.maxContextTokens}`);
await form.locator('#maxOutputTokens-dynamic-input').fill(`${MODEL_PARAMETERS.maxOutputTokens}`);
await form
.locator('#temperature-dynamic-setting-input-number')
.fill(`${MODEL_PARAMETERS.temperature}`);
await form.locator('#topP-dynamic-setting-input-number').fill(`${MODEL_PARAMETERS.topP}`);
await form.locator('#topK-dynamic-setting-input-number').fill(`${MODEL_PARAMETERS.topK}`);
await form.locator('#thinkingBudget-dynamic-input').fill(`${MODEL_PARAMETERS.thinkingBudget}`);
await form.locator('#fileTokenLimit-dynamic-input').fill(`${MODEL_PARAMETERS.fileTokenLimit}`);
await setSwitch(form, 'Resend Files', MODEL_PARAMETERS.resendFiles);
await setSwitch(form, 'Use Prompt Caching', MODEL_PARAMETERS.promptCache);
await setSwitch(form, 'Thinking', MODEL_PARAMETERS.thinking);
await setSwitch(form, 'Web Search', MODEL_PARAMETERS.web_search);
// DynamicInput/DynamicSlider values use a 450ms debounced form update.
await page.waitForTimeout(600);
}
async function expectAnthropicStyleModelParameters(page: Page) {
const form = page.getByRole('form', { name: 'Agent configuration form' });
await expect(form.locator('#maxContextTokens-dynamic-input')).toHaveValue(
`${MODEL_PARAMETERS.maxContextTokens}`,
);
await expect(form.locator('#maxOutputTokens-dynamic-input')).toHaveValue(
`${MODEL_PARAMETERS.maxOutputTokens}`,
);
await expect(form.locator('#temperature-dynamic-setting-input-number')).toHaveValue(
`${MODEL_PARAMETERS.temperature}`,
);
await expect(form.locator('#topP-dynamic-setting-input-number')).toHaveValue(
MODEL_PARAMETERS.topP.toFixed(2),
);
await expect(form.locator('#topK-dynamic-setting-input-number')).toHaveValue(
`${MODEL_PARAMETERS.topK}`,
);
await expect(form.locator('#thinkingBudget-dynamic-input')).toHaveValue(
`${MODEL_PARAMETERS.thinkingBudget}`,
);
await expect(form.locator('#fileTokenLimit-dynamic-input')).toHaveValue(
`${MODEL_PARAMETERS.fileTokenLimit}`,
);
await expect(form.getByRole('switch', { name: 'Resend Files', exact: true })).toHaveAttribute(
'aria-checked',
String(MODEL_PARAMETERS.resendFiles),
);
await expect(
form.getByRole('switch', { name: 'Use Prompt Caching', exact: true }),
).toHaveAttribute('aria-checked', String(MODEL_PARAMETERS.promptCache));
await expect(form.getByRole('switch', { name: 'Thinking', exact: true })).toHaveAttribute(
'aria-checked',
String(MODEL_PARAMETERS.thinking),
);
await expect(form.getByRole('switch', { name: 'Web Search', exact: true })).toHaveAttribute(
'aria-checked',
String(MODEL_PARAMETERS.web_search),
);
}
test.describe('agent builder', () => {
test('creates an agent, persists its configuration, and can chat with it', async ({ page }) => {
test.setTimeout(120000);
const agentName = uniqueAgentName('E2E Agent');
let createdAgentId: string | undefined;
try {
let form = await openAgentBuilder(page);
await form.getByLabel('Agent name').fill(agentName);
await form.getByLabel('Agent description').fill(DESCRIPTION);
await form.getByLabel('Agent instructions').fill(INSTRUCTIONS);
await selectMockModel(page);
await fillAnthropicStyleModelParameters(page);
await page
.getByRole('form', { name: 'Agent configuration form' })
.getByRole('button', {
name: 'Back to builder',
})
.click();
form = page.getByRole('form', { name: 'Agent configuration form' });
const [createResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
new URL(response.url()).pathname === '/api/agents' &&
response.status() === 201,
{ timeout: 30000 },
),
form.getByRole('button', { name: 'Create' }).click(),
]);
const createdAgent = (await createResponse.json()) as AgentDetail;
createdAgentId = createdAgent.id;
await expect(
page.getByText(`Successfully created ${agentName}`, { exact: true }),
).toBeVisible();
const persistedAgent = await waitForPersistedAgent(page, agentName, DESCRIPTION);
expect(persistedAgent).toMatchObject({
id: createdAgentId,
name: agentName,
description: DESCRIPTION,
instructions: INSTRUCTIONS,
provider: MOCK_ENDPOINTS[0].label,
model: MOCK_ENDPOINTS[0].model,
category: 'general',
});
expect(persistedAgent.model_parameters).toMatchObject(PERSISTED_MODEL_PARAMETERS);
form = await openAgentBuilder(page);
await form.getByRole('combobox', { name: 'Agent', exact: true }).click();
await page.getByRole('option', { name: agentName }).click();
await expect(form.getByLabel('Agent name')).toHaveValue(agentName);
await expect(form.getByLabel('Agent description')).toHaveValue(DESCRIPTION);
await expect(form.getByLabel('Agent instructions')).toHaveValue(INSTRUCTIONS);
await form.locator('label[for="provider"] + button').click();
await expectAnthropicStyleModelParameters(page);
await form.getByRole('button', { name: 'Back to builder' }).click();
await form.getByRole('button', { name: 'Select Agent' }).click();
const response = await sendMessage(page, `hello from ${agentName}`);
expect(response.ok()).toBeTruthy();
await expect(mockReply(page)).toBeVisible({ timeout: 30000 });
} finally {
await cleanupAgent(page, createdAgentId);
}
});
});