This is an automated email from the ASF dual-hosted git repository.
young 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 855218205 test: add E2E tests for plugin metadata resource (#3247)
855218205 is described below
commit 85521820539677ac84761fbfdfb6ebb31fd5a323
Author: Deep Shekhar Singh <[email protected]>
AuthorDate: Fri Nov 21 07:34:57 2025 +0530
test: add E2E tests for plugin metadata resource (#3247)
* test(plugin_metadata): add E2E tests for plugin metadata resource
- Add POM (Page Object Model) for plugin metadata pages
- Implement list page tests for navigation and search functionality
- Implement CRUD tests with required fields only (simple metadata)
- Implement CRUD tests with all fields (comprehensive metadata)
- Tests verify add, edit, and delete operations
- All 4 tests passing
Closes #3089
* test(e2e): add verification after editing plugin metadata
- Add API verification to confirm configuration changes persist
- Verify updated fields (time_iso8601, http_user_agent) appear in saved
metadata
- Address review feedback requesting verification of configuration changes
* test(e2e): enhance plugin metadata dialog interactions with helper
functions
---
e2e/pom/plugin_metadata.ts | 45 +++++
e2e/tests/plugin_metadata.crud-all-fields.spec.ts | 222 +++++++++++++++++++++
.../plugin_metadata.crud-required-fields.spec.ts | 143 +++++++++++++
e2e/tests/plugin_metadata.list.spec.ts | 83 ++++++++
4 files changed, 493 insertions(+)
diff --git a/e2e/pom/plugin_metadata.ts b/e2e/pom/plugin_metadata.ts
new file mode 100644
index 000000000..fa2a64a3e
--- /dev/null
+++ b/e2e/pom/plugin_metadata.ts
@@ -0,0 +1,45 @@
+/**
+ * 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.
+ */
+import { uiGoto } from '@e2e/utils/ui';
+import { expect, type Page } from '@playwright/test';
+
+const locator = {
+ getPluginMetadataNavBtn: (page: Page) =>
+ page.getByRole('link', { name: 'Plugin Metadata', exact: true }),
+ getSelectPluginsBtn: (page: Page) =>
+ page.getByRole('button', { name: 'Select Plugins' }),
+};
+
+const assert = {
+ isIndexPage: async (page: Page) => {
+ await expect(page).toHaveURL((url) =>
+ url.pathname.endsWith('/plugin_metadata')
+ );
+ const title = page.getByRole('heading', { name: 'Plugin Metadata' });
+ await expect(title).toBeVisible();
+ },
+};
+
+const goto = {
+ toIndex: (page: Page) => uiGoto(page, '/plugin_metadata'),
+};
+
+export const pluginMetadataPom = {
+ ...locator,
+ ...assert,
+ ...goto,
+};
diff --git a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
new file mode 100644
index 000000000..4f495bc15
--- /dev/null
+++ b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
@@ -0,0 +1,222 @@
+/**
+ * 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.
+ */
+import { pluginMetadataPom } from '@e2e/pom/plugin_metadata';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import {
+ uiFillMonacoEditor,
+ uiGetMonacoEditor,
+ uiHasToastMsg,
+} from '@e2e/utils/ui';
+import type { Locator } from '@playwright/test';
+import { expect } from '@playwright/test';
+
+import { API_PLUGIN_METADATA } from '@/config/constant';
+
+// Helper function to delete plugin metadata
+const deletePluginMetadata = async (req: typeof e2eReq, name: string) => {
+ await req.delete(`${API_PLUGIN_METADATA}/${name}`).catch(() => {
+ // Ignore errors if metadata doesn't exist
+ });
+};
+const getMonacoEditorValue = async (editPluginDialog: Locator) => {
+ let editorValue = '';
+ const textarea = editPluginDialog.locator('textarea');
+ if (await textarea.count() > 0) {
+ editorValue = await textarea.inputValue();
+ }
+ if (!editorValue || editorValue.trim() === '{') {
+ const lines = await
editPluginDialog.locator('.view-line').allTextContents();
+ editorValue = lines.join('\n').replace(/\s+/g, ' ');
+ }
+ if (!editorValue || editorValue.trim() === '{') {
+ const allText = await editPluginDialog.textContent();
+ console.log('DEBUG: editorValue fallback failed, dialog text:', allText);
+ }
+ return editorValue;
+};
+
+// Helper function to close edit dialog
+const closeEditDialog = async (editPluginDialog: Locator) => {
+ const buttons = await editPluginDialog.locator('button').allTextContents();
+ console.log('DEBUG: Edit Plugin dialog buttons:', buttons);
+ let closed = false;
+ for (const [i, name] of buttons.entries()) {
+ if (name.trim().toLowerCase() === 'cancel') {
+ await editPluginDialog.locator('button').nth(i).click();
+ closed = true;
+ break;
+ }
+ }
+ if (!closed && buttons.length > 0) {
+ await editPluginDialog.locator('button').first().click();
+ }
+};
+
+test.beforeAll(async () => {
+ await deletePluginMetadata(e2eReq, 'http-logger');
+});
+
+test.afterAll(async () => {
+ await deletePluginMetadata(e2eReq, 'http-logger');
+});
+
+test('should CRUD plugin metadata with all fields', async ({ page }) => {
+ await pluginMetadataPom.toIndex(page);
+ await pluginMetadataPom.isIndexPage(page);
+
+ await test.step('add plugin metadata with comprehensive configuration',
async () => {
+ // Click Select Plugins button
+ await pluginMetadataPom.getSelectPluginsBtn(page).click();
+
+ // Select Plugins dialog should appear
+ const selectPluginsDialog = page.getByRole('dialog', {
+ name: 'Select Plugins',
+ });
+ await expect(selectPluginsDialog).toBeVisible();
+
+ // Search for http-logger plugin
+ const searchInput = selectPluginsDialog.getByPlaceholder('Search');
+ await searchInput.fill('http-logger');
+
+ // Click Add button for http-logger
+ await selectPluginsDialog
+ .getByTestId('plugin-http-logger')
+ .getByRole('button', { name: 'Add' })
+ .click();
+
+ // Add Plugin dialog should appear
+ const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' });
+ await expect(addPluginDialog).toBeVisible();
+
+ // Fill in comprehensive configuration with all available fields
+ const pluginEditor = await uiGetMonacoEditor(page, addPluginDialog);
+ await uiFillMonacoEditor(
+ page,
+ pluginEditor,
+ JSON.stringify({
+ log_format: {
+ host: '$host',
+ client_ip: '$remote_addr',
+ request_method: '$request_method',
+ request_uri: '$request_uri',
+ status: '$status',
+ body_bytes_sent: '$body_bytes_sent',
+ request_time: '$request_time',
+ upstream_response_time: '$upstream_response_time',
+ },
+ })
+ );
+
+ // Click Add button
+ await addPluginDialog.getByRole('button', { name: 'Add' }).click();
+
+ // Should show success message
+ await uiHasToastMsg(page, {
+ hasText: 'success',
+ });
+
+ // Dialog should close
+ await expect(addPluginDialog).toBeHidden();
+
+ // Plugin card should now be visible
+ const httpLoggerCard = page.getByTestId('plugin-http-logger');
+ await expect(httpLoggerCard).toBeVisible();
+ });
+
+ await test.step('edit plugin metadata with extended fields', async () => {
+ // Find the http-logger card
+ const httpLoggerCard = page.getByTestId('plugin-http-logger');
+
+ // Click Edit button
+ await httpLoggerCard.getByRole('button', { name: 'Edit' }).click();
+
+ // Edit Plugin dialog should appear
+ const editPluginDialog = page.getByRole('dialog', { name: 'Edit Plugin' });
+ await expect(editPluginDialog).toBeVisible();
+
+ // Verify existing configuration is shown
+ await expect(editPluginDialog.getByText('log_format')).toBeVisible();
+
+ // Update the configuration with additional fields
+ const pluginEditor = await uiGetMonacoEditor(page, editPluginDialog);
+ await uiFillMonacoEditor(
+ page,
+ pluginEditor,
+ JSON.stringify({
+ log_format: {
+ host: '$host',
+ client_ip: '$remote_addr',
+ request_method: '$request_method',
+ request_uri: '$request_uri',
+ status: '$status',
+ body_bytes_sent: '$body_bytes_sent',
+ request_time: '$request_time',
+ upstream_response_time: '$upstream_response_time',
+ time: '$time_iso8601',
+ user_agent: '$http_user_agent',
+ },
+ })
+ );
+
+ // Click Save button
+ await editPluginDialog.getByRole('button', { name: 'Save' }).click();
+
+ // Should show success message
+ await uiHasToastMsg(page, {
+ hasText: 'success',
+ });
+
+ // Dialog should close
+ await expect(editPluginDialog).toBeHidden();
+ });
+
+ await test.step('verify configuration changes were saved', async () => {
+ // Re-open the edit dialog via UI
+ const httpLoggerCard = page.getByTestId('plugin-http-logger');
+ await httpLoggerCard.getByRole('button', { name: 'Edit' }).click();
+ const editPluginDialog = page.getByRole('dialog', { name: 'Edit Plugin' });
+ await expect(editPluginDialog).toBeVisible();
+
+ // Get Monaco editor value using helper
+ const editorValue = await getMonacoEditorValue(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"/);
+ expect(editorValue).toMatch(/"client_ip"\s*:\s*"\$remote_addr"/);
+
+ // Close the dialog using helper
+ await closeEditDialog(editPluginDialog);
+ await expect(editPluginDialog).toBeHidden();
+ });
+
+ await test.step('delete plugin metadata', async () => {
+ // Find the http-logger card
+ const httpLoggerCard = page.getByTestId('plugin-http-logger');
+
+ // Click Delete button
+ await httpLoggerCard.getByRole('button', { name: 'Delete' }).click();
+
+ // Should show success message
+ await uiHasToastMsg(page, {
+ hasText: 'success',
+ });
+
+ // Card should be removed
+ await expect(httpLoggerCard).toBeHidden();
+ });
+});
diff --git a/e2e/tests/plugin_metadata.crud-required-fields.spec.ts
b/e2e/tests/plugin_metadata.crud-required-fields.spec.ts
new file mode 100644
index 000000000..1175fefb9
--- /dev/null
+++ b/e2e/tests/plugin_metadata.crud-required-fields.spec.ts
@@ -0,0 +1,143 @@
+/**
+ * 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.
+ */
+import { pluginMetadataPom } from '@e2e/pom/plugin_metadata';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import {
+ uiFillMonacoEditor,
+ uiGetMonacoEditor,
+ uiHasToastMsg,
+} from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { API_PLUGIN_METADATA } from '@/config/constant';
+
+// Helper function to delete plugin metadata
+const deletePluginMetadata = async (req: typeof e2eReq, name: string) => {
+ await req.delete(`${API_PLUGIN_METADATA}/${name}`).catch(() => {
+ // Ignore errors if metadata doesn't exist
+ });
+};
+
+test.beforeAll(async () => {
+ await deletePluginMetadata(e2eReq, 'syslog');
+});
+
+test.afterAll(async () => {
+ await deletePluginMetadata(e2eReq, 'syslog');
+});
+
+test('should CRUD plugin metadata with required fields only', async ({
+ page,
+}) => {
+ await pluginMetadataPom.toIndex(page);
+ await pluginMetadataPom.isIndexPage(page);
+
+ await test.step('add plugin metadata with simple configuration', async () =>
{
+ // Click Select Plugins button
+ await pluginMetadataPom.getSelectPluginsBtn(page).click();
+
+ // Select Plugins dialog should appear
+ const selectPluginsDialog = page.getByRole('dialog', {
+ name: 'Select Plugins',
+ });
+ await expect(selectPluginsDialog).toBeVisible();
+
+ // Search for syslog plugin
+ const searchInput = selectPluginsDialog.getByPlaceholder('Search');
+ await searchInput.fill('syslog');
+
+ // Click Add button for syslog
+ await selectPluginsDialog
+ .getByTestId('plugin-syslog')
+ .getByRole('button', { name: 'Add' })
+ .click();
+
+ // Add Plugin dialog should appear
+ const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' });
+ await expect(addPluginDialog).toBeVisible();
+
+ // Fill in minimal required configuration
+ const pluginEditor = await uiGetMonacoEditor(page, addPluginDialog);
+ await uiFillMonacoEditor(page, pluginEditor, '{"host": "127.0.0.1"}');
+
+ // Click Add button
+ await addPluginDialog.getByRole('button', { name: 'Add' }).click();
+
+ // Should show success message
+ await uiHasToastMsg(page, {
+ hasText: 'success',
+ });
+
+ // Dialog should close
+ await expect(addPluginDialog).toBeHidden();
+
+ // Plugin card should now be visible
+ const syslogCard = page.getByTestId('plugin-syslog');
+ await expect(syslogCard).toBeVisible();
+ });
+
+ await test.step('edit plugin metadata with simple update', async () => {
+ // Find the syslog card
+ const syslogCard = page.getByTestId('plugin-syslog');
+
+ // Click Edit button
+ await syslogCard.getByRole('button', { name: 'Edit' }).click();
+
+ // Edit Plugin dialog should appear
+ const editPluginDialog = page.getByRole('dialog', { name: 'Edit Plugin' });
+ await expect(editPluginDialog).toBeVisible();
+
+ // Verify existing configuration is shown
+ await expect(editPluginDialog.getByText('host')).toBeVisible();
+
+ // Update the configuration
+ const pluginEditor = await uiGetMonacoEditor(page, editPluginDialog);
+ await uiFillMonacoEditor(
+ page,
+ pluginEditor,
+ '{"host": "127.0.0.1", "port": 5140}'
+ );
+
+ // Click Save button
+ await editPluginDialog.getByRole('button', { name: 'Save' }).click();
+
+ // Should show success message
+ await uiHasToastMsg(page, {
+ hasText: 'success',
+ });
+
+ // Dialog should close
+ await expect(editPluginDialog).toBeHidden();
+ });
+
+ await test.step('delete plugin metadata', async () => {
+ // Find the syslog card
+ const syslogCard = page.getByTestId('plugin-syslog');
+
+ // Click Delete button
+ await syslogCard.getByRole('button', { name: 'Delete' }).click();
+
+ // Should show success message
+ await uiHasToastMsg(page, {
+ hasText: 'success',
+ });
+
+ // Card should be removed
+ await expect(syslogCard).toBeHidden();
+ });
+});
diff --git a/e2e/tests/plugin_metadata.list.spec.ts
b/e2e/tests/plugin_metadata.list.spec.ts
new file mode 100644
index 000000000..c6b90cd27
--- /dev/null
+++ b/e2e/tests/plugin_metadata.list.spec.ts
@@ -0,0 +1,83 @@
+/**
+ * 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.
+ */
+
+import { pluginMetadataPom } from '@e2e/pom/plugin_metadata';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+import { API_PLUGIN_METADATA } from '@/config/constant';
+
+// Helper function to delete all plugin metadata
+const deleteAllPluginMetadata = async (req: typeof e2eReq) => {
+ // Plugin metadata doesn't have a list endpoint, so we'll delete known
plugins
+ const pluginsToClean = [
+ 'http-logger',
+ 'syslog',
+ 'skywalking',
+ 'error-log-logger',
+ ];
+ await Promise.all(
+ pluginsToClean.map((name) =>
+ req.delete(`${API_PLUGIN_METADATA}/${name}`).catch(() => {
+ // Ignore errors if metadata doesn't exist
+ })
+ )
+ );
+};
+
+test.beforeAll(async () => {
+ await deleteAllPluginMetadata(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllPluginMetadata(e2eReq);
+});
+
+test('should navigate to plugin metadata page', async ({ page }) => {
+ await test.step('navigate to plugin metadata page', async () => {
+ await pluginMetadataPom.getPluginMetadataNavBtn(page).click();
+ await pluginMetadataPom.isIndexPage(page);
+ });
+
+ await test.step('verify plugin metadata page components', async () => {
+ // Search box should be visible
+ const searchBox = page.getByPlaceholder('Search');
+ await expect(searchBox).toBeVisible();
+
+ // Select Plugins button should be visible
+ await expect(pluginMetadataPom.getSelectPluginsBtn(page)).toBeVisible();
+ });
+});
+
+test('should search for plugin metadata', async ({ page }) => {
+ await pluginMetadataPom.toIndex(page);
+ await pluginMetadataPom.isIndexPage(page);
+
+ await test.step('search filters plugin cards', async () => {
+ const searchBox = page.getByPlaceholder('Search');
+ await searchBox.fill('http-logger');
+
+ // Only http-logger related cards should be visible if they exist
+ // For now just verify search box works
+ await expect(searchBox).toHaveValue('http-logger');
+
+ // Clear search
+ await searchBox.clear();
+ await expect(searchBox).toHaveValue('');
+ });
+});