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
121 changes: 121 additions & 0 deletions e2e/config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import type { Page } from '@playwright/test';

const TABS = ['providers', 'custom', 'specs', 'mcp', 'features', 'files', 'system'];
const TEXT_INPUTS =
'form input[type=text]:visible:enabled, form input[type=number]:visible:enabled, form input:not([type]):visible:enabled';
const MAX_INPUTS_PER_TAB = 30;

async function openTab(page: Page, tab: string) {
await page.goto(`/configuration?tab=${tab}`);
await page.waitForLoadState('networkidle');
}

/** Opens collapsed sections and entry cards so their inputs can be reached (select triggers are left alone). */
async function expandAll(page: Page) {
for (let round = 0; round < 2; round++) {
const toggles = await page
.locator('form button[aria-expanded=false]:not([aria-haspopup]):not([role=combobox])')
.all();
for (const toggle of toggles.slice(0, 12)) {
await toggle.click({ timeout: 1_000 }).catch(() => undefined);
}
}
const cards = await page.locator('form [role=button][aria-expanded=false]').all();
for (const card of cards.slice(0, 3)) await card.click({ timeout: 1_000 }).catch(() => undefined);
}

const unsavedBar = (page: Page) => page.getByText('Unsaved changes');

test.describe('Configuration: Enter never edits another field', () => {
test.describe.configure({ timeout: 120_000 });

for (const tab of TABS) {
test(`Enter in each input on the ${tab} tab leaves the page clean`, async ({ page }) => {
await openTab(page, tab);
await expandAll(page);
await expect(unsavedBar(page)).toHaveCount(0);
const inputs = page.locator(TEXT_INPUTS);
const count = Math.min(await inputs.count(), MAX_INPUTS_PER_TAB);
const cardsBefore = await page.locator('form [role=button][aria-expanded]').count();
for (let i = 0; i < count; i++) {
const input = inputs.nth(i);
await input.focus();
await input.press('Enter');
await expect(unsavedBar(page), `Enter in input #${i} made the page dirty`).toHaveCount(0);
}
await expect(page.locator('form [role=button][aria-expanded]')).toHaveCount(cardsBefore);
});
}

test('Enter after editing a value marks only that field', async ({ page }) => {
await openTab(page, 'system');
const field = page.locator('#balance-startBalance');
await field.fill('424242');
await field.press('Enter');
await expect(unsavedBar(page)).toHaveCount(1);
await page
.getByRole('button', { name: /^Save$/ })
.last()
.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toContainText('1 field will be updated');
await expect(dialog).toContainText('balance.startBalance');
await dialog.getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Discard' }).click();
});

test('Enter in a create dialog still submits that dialog', async ({ page }) => {
await openTab(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 baseURL = dialog.locator('#create-endpoint-baseURL');
await baseURL.fill('https://enter.example.com/v1');
await baseURL.press('Enter');
await expect(dialog).toHaveCount(0);
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 }) => {
await openTab(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 })
.include('form')
.withRules(['button-name'])
.analyze();
expect(results.violations).toEqual([]);
});

test('keyboard Enter on a card trash button deletes the entry without toggling the card', async ({
page,
}) => {
await openTab(page, 'custom');
const headers = page.locator('form [role=button][aria-expanded]');
const before = await headers.count();
test.skip(before === 0, 'No custom endpoints to delete');
const header = headers.first();
const trash = header.locator('button.icon-btn-danger');
await trash.focus();
await page.keyboard.press('Enter');
await expect(headers).toHaveCount(before - 1);
await expect(headers.first()).toHaveAttribute('aria-expanded', 'false');
await expect(unsavedBar(page)).toHaveCount(1);
await page.getByRole('button', { name: 'Discard' }).click();
});
});
10 changes: 9 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import tsPlugin from '@typescript-eslint/eslint-plugin';
import importPlugin from 'eslint-plugin-import-x';
import tailwindCanonicalClasses from 'eslint-plugin-tailwind-canonical-classes';
import clickUiPlugin from 'eslint-plugin-click-ui';
import requireButtonHtmlType from './tools/eslint-plugin-click-ui/rules/require-button-html-type.js';

/** Loads rules from the in-repo plugin source so a stale installed copy cannot hide new rules. */
const clickUi = {
...clickUiPlugin,
rules: { ...clickUiPlugin.rules, 'require-button-html-type': requireButtonHtmlType },
};

export default [
{
Expand All @@ -17,7 +24,7 @@ export default [
'@typescript-eslint': tsPlugin,
'import-x': importPlugin,
'tailwind-canonical-classes': tailwindCanonicalClasses,
'click-ui': clickUiPlugin,
'click-ui': clickUi,
},
rules: {
'@typescript-eslint/ban-ts-comment': ['error', { 'ts-ignore': false }],
Expand Down Expand Up @@ -53,6 +60,7 @@ export default [
...clickUiPlugin.configs.recommended.rules,
'click-ui/require-provider': 'off',
'click-ui/select-requires-options': 'off',
'click-ui/require-button-html-type': 'error',
},
},
];
2 changes: 2 additions & 0 deletions src/components/AuthCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ export function AuthCard({
/>

<Button
htmlType="button"
label={isSubmitting ? localize('com_auth_signing_in') : localize('com_auth_sign_in')}
type="primary"
onClick={handleLogin}
Expand All @@ -327,6 +328,7 @@ export function AuthCard({
<>
<Separator size="sm" />
<Button
htmlType="button"
label={
ssoLoading
? localize('com_auth_sso_redirecting')
Expand Down
2 changes: 2 additions & 0 deletions src/components/access/CreateGroupDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,12 +167,14 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) {
)}
<div className="flex items-center justify-end gap-2">
<Button
htmlType="button"
type="secondary"
label={localize('com_ui_cancel')}
onClick={resetAndClose}
disabled={mutation.isPending}
/>
<Button
htmlType="submit"
type="primary"
label={localize('com_access_create_group')}
disabled={!name.trim() || mutation.isPending}
Expand Down
2 changes: 2 additions & 0 deletions src/components/access/CreateRoleDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,12 +187,14 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) {
)}
<div className="flex items-center justify-end gap-2">
<Button
htmlType="button"
type="secondary"
label={localize('com_ui_cancel')}
onClick={resetAndClose}
disabled={mutation.isPending}
/>
<Button
htmlType="submit"
type="primary"
label={localize('com_access_create_role')}
disabled={!name.trim() || mutation.isPending}
Expand Down
2 changes: 2 additions & 0 deletions src/components/access/EditGroupDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,12 +260,14 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog
)}
<div className="flex items-center justify-end gap-2">
<Button
htmlType="button"
type="secondary"
label={localize('com_ui_cancel')}
onClick={onClose}
disabled={mutation.isPending}
/>
<Button
htmlType="submit"
type="primary"
label={localize('com_ui_save')}
disabled={
Expand Down
3 changes: 3 additions & 0 deletions src/components/access/EditRoleDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro
<p>{localize('com_access_permissions_load_error')}</p>
{roleDetail.isError && (
<Button
htmlType="button"
type="secondary"
label={localize('com_ui_retry')}
onClick={() => roleDetail.refetch()}
Expand Down Expand Up @@ -349,12 +350,14 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro
)}
<div className="flex items-center justify-end gap-2">
<Button
htmlType="button"
type="secondary"
label={localize('com_ui_cancel')}
onClick={onClose}
disabled={updateMutation.isPending}
/>
<Button
htmlType="submit"
type="primary"
label={localize('com_ui_save')}
disabled={
Expand Down
1 change: 1 addition & 0 deletions src/components/access/GroupsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) {
placeholder={localize('com_access_search_groups')}
/>
<Button
htmlType="button"
type="secondary"
iconLeft="plus"
label={localize('com_access_create_group')}
Expand Down
1 change: 1 addition & 0 deletions src/components/access/RolesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) {
placeholder={localize('com_access_search_roles')}
/>
<Button
htmlType="button"
type="secondary"
iconLeft="plus"
label={localize('com_access_create_role')}
Expand Down
Loading
Loading