This is an automated email from the ASF dual-hosted git repository.
LiteSun pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git
The following commit(s) were added to refs/heads/master by this push:
new c0edff509 fix: plugin card grid semantics, and read Monaco from the
model in e2e (#3465)
c0edff509 is described below
commit c0edff509b69be82626a1a709c205c4e7ba9540c
Author: Joe Castle <[email protected]>
AuthorDate: Fri Oct 16 03:51:59 2026 -0400
fix: plugin card grid semantics, and read Monaco from the model in e2e
(#3465)
---
e2e/tests/plugin_metadata.crud-all-fields.spec.ts | 31 +-------
e2e/tests/regression/a11y.plugin-card-list.spec.ts | 92 ++++++++++++++++++++++
e2e/tests/routes.empty-plugin-config.spec.ts | 7 +-
e2e/utils/ui/index.ts | 34 ++++++++
package.json | 1 +
pnpm-lock.yaml | 19 +++++
.../form-slice/FormItemPlugins/PluginCardList.tsx | 47 +++++------
.../FormItemPlugins/PluginEditorDrawer.tsx | 2 +-
.../FormItemPlugins/SelectPluginsDrawer.tsx | 1 +
src/locales/de/common.json | 2 +
src/locales/en/common.json | 2 +
src/locales/es/common.json | 2 +
src/locales/tr/common.json | 2 +
src/locales/zh/common.json | 2 +
14 files changed, 186 insertions(+), 58 deletions(-)
diff --git a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
index 4a1fe6070..5c4be7ff0 100644
--- a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
+++ b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
@@ -20,6 +20,7 @@ import { test } from '@e2e/utils/test';
import {
uiFillMonacoEditor,
uiGetMonacoEditor,
+ uiGetMonacoEditorValue,
uiHasToastMsg,
} from '@e2e/utils/ui';
import type { Locator } from '@playwright/test';
@@ -33,34 +34,6 @@ const deletePluginMetadata = async (req: typeof e2eReq,
name: string) => {
// Ignore errors if metadata doesn't exist
});
};
-const getMonacoEditorValue = async (editPluginDialog: Locator) => {
- const textarea = editPluginDialog.locator('textarea');
-
- // Wait for Monaco editor to be fully loaded with content (increased timeout
for CI)
- await textarea.waitFor({ state: 'attached', timeout: 10000 });
-
- let editorValue = '';
-
- // Try to get value from textarea first
- if (await textarea.count() > 0) {
- editorValue = await textarea.inputValue();
- }
-
- // Fallback to reading view-lines if textarea value is incomplete
- if (!editorValue || editorValue.trim() === '{') {
- // Wait for view-lines to be populated
- await editPluginDialog.locator('.view-line').first().waitFor({ timeout:
10000 });
- const lines = await
editPluginDialog.locator('.view-line').allTextContents();
- editorValue = lines.join('\n').replace(/\s+/g, ' ');
- }
-
- if (!editorValue || editorValue.trim() === '{') {
- const allText = await editPluginDialog.textContent();
- void allText; // fallback failed, editor value unavailable
- }
- return editorValue;
-};
-
// Helper function to close edit dialog
const closeEditDialog = async (editPluginDialog: Locator) => {
const buttons = await editPluginDialog.locator('button').allTextContents();
@@ -203,7 +176,7 @@ test('should CRUD plugin metadata with all fields', async
({ page }) => {
await expect(editPluginDialog).toBeVisible();
// Get Monaco editor value using helper
- const editorValue = await getMonacoEditorValue(editPluginDialog);
+ const editorValue = await uiGetMonacoEditorValue(page, editPluginDialog);
expect(editorValue).toMatch(/"time"\s*:\s*"\$time_iso8601"/);
expect(editorValue).toMatch(/"user_agent"\s*:\s*"\$http_user_agent"/);
expect(editorValue).toMatch(/"host"\s*:\s*"\$host"/);
diff --git a/e2e/tests/regression/a11y.plugin-card-list.spec.ts
b/e2e/tests/regression/a11y.plugin-card-list.spec.ts
new file mode 100644
index 000000000..555625fee
--- /dev/null
+++ b/e2e/tests/regression/a11y.plugin-card-list.spec.ts
@@ -0,0 +1,92 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Regression for the "plugin cards are wrapped in Combobox/role=option
+// semantics with nested interactive buttons and no keyboard wiring" a11y item
+// of apache/apisix-dashboard#3417, explicitly deferred by #3442.
+//
+// The card grid was rendered inside a Combobox whose store was never wired to
+// a target and whose onOptionSubmit was never set, so every card carried
+// role="option" while containing a real button and a real link. That is an
+// aria-allowed-descendants violation (axe: nested-interactive), and it lied to
+// screen readers about a listbox the user could not operate as one.
+
+import { AxeBuilder } from '@axe-core/playwright';
+import { routesPom } from '@e2e/pom/routes';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+const openSelectPluginsDrawer = async (page: Parameters<typeof
routesPom.toAdd>[0]) => {
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+ await page.getByRole('button', { name: 'Select Plugins' }).click();
+ const drawer = page.getByRole('dialog', { name: 'Select Plugins' });
+ await expect(drawer).toBeVisible();
+ await expect(drawer.getByTestId(/^plugin-/).first()).toBeVisible();
+ return drawer;
+};
+
+test('the plugin card grid is a list, not a fake listbox', async ({ page }) =>
{
+ const drawer = await openSelectPluginsDrawer(page);
+
+ // no combobox semantics remain anywhere in the drawer
+ await expect(drawer.locator('[role="listbox"]')).toHaveCount(0);
+ await expect(drawer.locator('[role="option"]')).toHaveCount(0);
+
+ // the cards are a real list, so a screen reader announces the item count
+ const list = drawer.getByRole('list');
+ await expect(list).toHaveCount(1);
+ // one list item per rendered plugin card
+ const cardCount = await drawer.getByTestId(/^plugin-/).count();
+ expect(cardCount).toBeGreaterThan(0);
+ await expect(list.getByRole('listitem')).toHaveCount(cardCount);
+});
+
+test('plugin cards expose no nested-interactive violations', async ({
+ page,
+}) => {
+ await openSelectPluginsDrawer(page);
+
+ const results = await new AxeBuilder({ page })
+ .include('[role="dialog"]')
+ .withRules(['nested-interactive', 'aria-required-children',
'aria-required-parent'])
+ .analyze();
+
+ expect(results.violations).toEqual([]);
+});
+
+test('the card action buttons are reachable and operable by keyboard', async ({
+ page,
+}) => {
+ const drawer = await openSelectPluginsDrawer(page);
+
+ const firstAdd = drawer.getByRole('button', { name: 'Add' }).first();
+ await expect(firstAdd).toBeVisible();
+ // focusable in document order rather than needing listbox key handling,
+ // which the Combobox never provided
+ await firstAdd.focus();
+ await expect(firstAdd).toBeFocused();
+});
+
+test('the search clear button and the drawer close button have names', async ({
+ page,
+}) => {
+ const drawer = await openSelectPluginsDrawer(page);
+
+ await expect(drawer.getByRole('button', { name: 'Clear search'
})).toBeVisible();
+ await expect(drawer.getByRole('button', { name: 'Close' })).toBeVisible();
+});
diff --git a/e2e/tests/routes.empty-plugin-config.spec.ts
b/e2e/tests/routes.empty-plugin-config.spec.ts
index bff6a2a05..4d5953768 100644
--- a/e2e/tests/routes.empty-plugin-config.spec.ts
+++ b/e2e/tests/routes.empty-plugin-config.spec.ts
@@ -21,6 +21,7 @@ import { test } from '@e2e/utils/test';
import {
uiFillMonacoEditor,
uiGetMonacoEditor,
+ uiGetMonacoEditorValue,
uiHasToastMsg,
} from '@e2e/utils/ui';
import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
@@ -247,11 +248,7 @@ test('should preserve plugin with empty configuration
(key-auth) during creation
await pluginCard.getByRole('button', { name: 'View' }).click();
const viewPluginDialog = page.getByRole('dialog', { name: 'View Plugin' });
await uiGetMonacoEditor(page, viewPluginDialog, false);
- // Read Monaco editor content from the visible lines (not the textarea)
- const viewContent = await viewPluginDialog
- .locator('.view-lines')
- .first()
- .innerText();
+ const viewContent = await uiGetMonacoEditorValue(page, viewPluginDialog);
expect(viewContent.replace(/\s/g, '')).toBe('{}');
});
});
diff --git a/e2e/utils/ui/index.ts b/e2e/utils/ui/index.ts
index 88042097e..904d4c1a8 100644
--- a/e2e/utils/ui/index.ts
+++ b/e2e/utils/ui/index.ts
@@ -137,3 +137,37 @@ export const uiFillMonacoEditor = async (
await editor.blur();
await page.waitForTimeout(800);
};
+
+/**
+ * Read an editor's contents back.
+ *
+ * Reads the Monaco model, the same source `uiFillMonacoEditor` and
+ * `uiClearMonacoEditor` write to. Do not scrape the DOM for this: the hidden
+ * `textarea` holds only a small buffer around the cursor rather than the whole
+ * document, and `.view-line` elements are virtualised, so both return partial
+ * content until the editor has finished painting.
+ *
+ * Polls, because the drawer that owns the editor mounts it asynchronously and
+ * `window.__monacoEditor__` can still point at a previously mounted instance
+ * for a moment after a new drawer opens.
+ */
+export const uiGetMonacoEditorValue = async (page: Page, parent: Locator) => {
+ await expect(parent.getByTestId('editor-loading')).toBeHidden();
+ await expect(parent.locator('.monaco-editor').first()).toBeVisible({
+ timeout: 10_000,
+ });
+
+ await expect
+ .poll(
+ () =>
+ page.evaluate(
+ () => window.__monacoEditor__?.getModel()?.getValue().length ?? 0
+ ),
+ { timeout: 10_000 }
+ )
+ .toBeGreaterThan(0);
+
+ return page.evaluate(
+ () => window.__monacoEditor__?.getModel()?.getValue() ?? ''
+ );
+};
diff --git a/package.json b/package.json
index 1def1bfe6..e89d603d2 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"zod-empty": "^1.4.4"
},
"devDependencies": {
+ "@axe-core/playwright": "^4.13.0",
"@eslint/js": "^9.32.0",
"@estruyf/github-actions-reporter": "^1.11.0",
"@iconify-json/tabler": "^1.2.37",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 950a1e0c5..ebe8f9fac 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -122,6 +122,9 @@ importers:
specifier: ^1.4.4
version: 1.5.2([email protected])([email protected])
devDependencies:
+ '@axe-core/playwright':
+ specifier: ^4.13.0
+ version: 4.13.0([email protected])
'@eslint/js':
specifier: ^9.32.0
version: 9.39.4
@@ -381,6 +384,11 @@ packages:
'@antfu/[email protected]':
resolution: {integrity:
sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
+ '@axe-core/[email protected]':
+ resolution: {integrity:
sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==}
+ peerDependencies:
+ playwright-core: '>= 1.0.0'
+
'@babel/[email protected]':
resolution: {integrity:
sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -1858,6 +1866,10 @@ packages:
resolution: {integrity:
sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
+ [email protected]:
+ resolution: {integrity:
sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
+ engines: {node: '>=4'}
+
[email protected]:
resolution: {integrity:
sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
@@ -4491,6 +4503,11 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.0.2
+ '@axe-core/[email protected]([email protected])':
+ dependencies:
+ axe-core: 4.13.0
+ playwright-core: 1.58.2
+
'@babel/[email protected]':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -5945,6 +5962,8 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
+ [email protected]: {}
+
[email protected]:
dependencies:
follow-redirects: 1.16.0
diff --git a/src/components/form-slice/FormItemPlugins/PluginCardList.tsx
b/src/components/form-slice/FormItemPlugins/PluginCardList.tsx
index ab95c1dac..bd82bfecb 100644
--- a/src/components/form-slice/FormItemPlugins/PluginCardList.tsx
+++ b/src/components/form-slice/FormItemPlugins/PluginCardList.tsx
@@ -16,12 +16,11 @@
*/
import {
CloseButton,
- Combobox,
ScrollArea,
SimpleGrid,
+ Text,
TextInput,
type TextInputProps,
- useVirtualizedCombobox,
} from '@mantine/core';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -48,6 +47,7 @@ export const PluginCardListSearch = (props:
PluginCardListSearchProps) => {
rightSectionPointerEvents="all"
rightSection={
<CloseButton
+ aria-label={t('a11y.clearSearch')}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
@@ -59,16 +59,16 @@ export const PluginCardListSearch = (props:
PluginCardListSearchProps) => {
);
};
-type OptionProps = Pick<
+type PluginListItemProps = Pick<
PluginCardProps,
'onAdd' | 'onEdit' | 'onDelete' | 'onView' | 'mode'
> & {
name: string;
};
-const Option = (props: OptionProps) => {
+const PluginListItem = (props: PluginListItemProps) => {
const { mode, name, onAdd, onEdit, onDelete, onView } = props;
return (
- <Combobox.Option key={name} value={name} p={0}>
+ <li>
<PluginCard
mode={mode}
name={name}
@@ -77,22 +77,22 @@ const Option = (props: OptionProps) => {
onDelete={() => onDelete?.(name)}
onView={() => onView?.(name)}
/>
- </Combobox.Option>
+ </li>
);
};
-const Options = (props: { list: OptionProps[] }) => {
+const PluginListItems = (props: { list: PluginListItemProps[] }) => {
const { list } = props;
return (
<>
{list.map((option) => (
- <Option key={option.name} {...option} />
+ <PluginListItem key={option.name} {...option} />
))}
</>
);
};
-export type PluginCardListProps = Omit<OptionProps, 'name'> &
+export type PluginCardListProps = Omit<PluginListItemProps, 'name'> &
Pick<TextInputProps, 'placeholder'> & {
cols?: number;
h?: number | string;
@@ -105,7 +105,6 @@ export const PluginCardList = (props: PluginCardListProps)
=> {
const { search = '', cols = 3, h, mah, plugins } = props;
const { mode, onAdd, onEdit, onDelete, onView } = props;
const { t } = useTranslation();
- const combobox = useVirtualizedCombobox();
const list = useMemo(() => {
const query = search.toLowerCase().trim();
@@ -123,18 +122,20 @@ export const PluginCardList = (props:
PluginCardListProps) => {
}, [search, plugins, mode, onAdd, onEdit, onDelete, onView]);
return (
- <Combobox store={combobox}>
- <Combobox.Options mt="1em">
- <ScrollArea.Autosize h={h} mah={mah} type="scroll">
- {!list.length ? (
- <Combobox.Empty>{t('noData')}</Combobox.Empty>
- ) : (
- <SimpleGrid cols={cols}>
- <Options list={list} />
- </SimpleGrid>
- )}
- </ScrollArea.Autosize>
- </Combobox.Options>
- </Combobox>
+ <ScrollArea.Autosize mt="1em" h={h} mah={mah} type="scroll">
+ {!list.length ? (
+ <Text c="dimmed" ta="center" py="sm">
+ {t('noData')}
+ </Text>
+ ) : (
+ <SimpleGrid
+ component="ul"
+ cols={cols}
+ style={{ listStyle: 'none', margin: 0, padding: 0 }}
+ >
+ <PluginListItems list={list} />
+ </SimpleGrid>
+ )}
+ </ScrollArea.Autosize>
);
};
diff --git a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
index b883d9bc0..c41cf2bf6 100644
--- a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
+++ b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
@@ -108,7 +108,7 @@ export const PluginEditorDrawer = (props:
PluginEditorDrawerProps) => {
closeOnEscape={false}
opened={opened}
onClose={handleClose}
- closeButtonProps={{ 'aria-label': 'Close' }}
+ closeButtonProps={{ 'aria-label': t('a11y.close') }}
styles={{ body: { paddingTop: '18px' } }}
{...(mode === 'add' && { title: t('form.plugins.addPlugin') })}
{...(mode === 'edit' && { title: t('form.plugins.editPlugin') })}
diff --git a/src/components/form-slice/FormItemPlugins/SelectPluginsDrawer.tsx
b/src/components/form-slice/FormItemPlugins/SelectPluginsDrawer.tsx
index 1b459e840..604ab08d1 100644
--- a/src/components/form-slice/FormItemPlugins/SelectPluginsDrawer.tsx
+++ b/src/components/form-slice/FormItemPlugins/SelectPluginsDrawer.tsx
@@ -51,6 +51,7 @@ export const SelectPluginsDrawer = (props:
SelectPluginsDrawerProps) => {
// protect, so Escape should close it like any other dialog (#3417)
opened={opened}
onClose={() => setOpened(false)}
+ closeButtonProps={{ 'aria-label': t('a11y.close') }}
title={t('form.plugins.selectPlugins.title')}
>
<Drawer.Header p={0} mih="60px">
diff --git a/src/locales/de/common.json b/src/locales/de/common.json
index 60b822fe1..a9b04ac0a 100644
--- a/src/locales/de/common.json
+++ b/src/locales/de/common.json
@@ -418,6 +418,8 @@
}
},
"a11y": {
+ "clearSearch": "Suche zurücksetzen",
+ "close": "Schließen",
"openSettings": "Open settings",
"selectLanguage": "Select language",
"toggleNavigation": "Toggle navigation"
diff --git a/src/locales/en/common.json b/src/locales/en/common.json
index b896a6ef9..4dac293bc 100644
--- a/src/locales/en/common.json
+++ b/src/locales/en/common.json
@@ -418,6 +418,8 @@
}
},
"a11y": {
+ "clearSearch": "Clear search",
+ "close": "Close",
"openSettings": "Open settings",
"selectLanguage": "Select language",
"toggleNavigation": "Toggle navigation"
diff --git a/src/locales/es/common.json b/src/locales/es/common.json
index 635c9a48e..effabf5ad 100644
--- a/src/locales/es/common.json
+++ b/src/locales/es/common.json
@@ -418,6 +418,8 @@
}
},
"a11y": {
+ "clearSearch": "Borrar búsqueda",
+ "close": "Cerrar",
"openSettings": "Open settings",
"selectLanguage": "Select language",
"toggleNavigation": "Toggle navigation"
diff --git a/src/locales/tr/common.json b/src/locales/tr/common.json
index ed8e03371..f5cba5438 100644
--- a/src/locales/tr/common.json
+++ b/src/locales/tr/common.json
@@ -418,6 +418,8 @@
}
},
"a11y": {
+ "clearSearch": "Aramayı temizle",
+ "close": "Kapat",
"openSettings": "Open settings",
"selectLanguage": "Select language",
"toggleNavigation": "Toggle navigation"
diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json
index 84e323c40..de2767ace 100644
--- a/src/locales/zh/common.json
+++ b/src/locales/zh/common.json
@@ -418,6 +418,8 @@
}
},
"a11y": {
+ "clearSearch": "清除搜索",
+ "close": "关闭",
"openSettings": "打开设置",
"selectLanguage": "选择语言",
"toggleNavigation": "切换导航"