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 af28b8dc4 test: add E2E tests for protos (#3244)
af28b8dc4 is described below
commit af28b8dc4ea74558a9d948c3f2bdfac9d2fc2963
Author: Deep Shekhar Singh <[email protected]>
AuthorDate: Fri Nov 21 07:27:23 2025 +0530
test: add E2E tests for protos (#3244)
* feat: add E2E tests for protos resource (#3092)
- Add Page Object Model for protos pages
- Add list page tests with pagination
- Add CRUD tests with required fields only
- Add CRUD tests with all fields
- All 11 tests passing
Fixes #3092
* test: add UI verification to proto details page
- Navigate to proto details via list page and View button
- Verify proto content is displayed correctly in the UI
- Addresses feedback from code review
* test: use UI for update and delete operations in proto tests
- Update protos via UI (Edit button -> modify content -> Save)
- Delete protos via UI (Delete button -> confirm dialog)
- Add UI verification to read/view tests
- API now only used for initial setup verification and cleanup
- All 8 tests passing
Addresses feedback to use UI for all operations except setup/cleanup
---
e2e/pom/protos.ts | 60 ++++++++
e2e/tests/protos.crud-all-fields.spec.ts | 211 ++++++++++++++++++++++++++
e2e/tests/protos.crud-required-fields.spec.ts | 209 +++++++++++++++++++++++++
e2e/tests/protos.list.spec.ts | 96 ++++++++++++
4 files changed, 576 insertions(+)
diff --git a/e2e/pom/protos.ts b/e2e/pom/protos.ts
new file mode 100644
index 000000000..4ba902731
--- /dev/null
+++ b/e2e/pom/protos.ts
@@ -0,0 +1,60 @@
+/**
+ * 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 = {
+ getProtoNavBtn: (page: Page) =>
+ page.getByRole('link', { name: 'Protos' }),
+ getAddProtoBtn: (page: Page) =>
+ page.getByRole('button', { name: 'Add Proto' }),
+ getAddBtn: (page: Page) =>
+ page.getByRole('button', { name: 'Add', exact: true }),
+};
+
+const assert = {
+ isIndexPage: async (page: Page) => {
+ await expect(page).toHaveURL((url) => url.pathname.endsWith('/protos'));
+ const title = page.getByRole('heading', { name: 'Protos' });
+ await expect(title).toBeVisible();
+ },
+ isAddPage: async (page: Page) => {
+ await expect(page).toHaveURL((url) =>
+ url.pathname.endsWith('/protos/add')
+ );
+ const title = page.getByRole('heading', { name: 'Add Proto' });
+ await expect(title).toBeVisible();
+ },
+ isDetailPage: async (page: Page) => {
+ await expect(page).toHaveURL((url) =>
+ url.pathname.includes('/protos/detail')
+ );
+ const title = page.getByRole('heading', { name: 'Proto Detail' });
+ await expect(title).toBeVisible();
+ },
+};
+
+const goto = {
+ toIndex: (page: Page) => uiGoto(page, '/protos'),
+ toAdd: (page: Page) => uiGoto(page, '/protos/add'),
+};
+
+export const protosPom = {
+ ...locator,
+ ...assert,
+ ...goto,
+};
diff --git a/e2e/tests/protos.crud-all-fields.spec.ts
b/e2e/tests/protos.crud-all-fields.spec.ts
new file mode 100644
index 000000000..7fdd23735
--- /dev/null
+++ b/e2e/tests/protos.crud-all-fields.spec.ts
@@ -0,0 +1,211 @@
+/**
+ * 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 { protosPom } from '@e2e/pom/protos';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+import { API_PROTOS } from '@/config/constant';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const protoContent = `syntax = "proto3";
+package test;
+
+message TestMessage {
+ string name = 1;
+ int32 age = 2;
+ string email = 3;
+}`;
+
+let createdProtoId: string;
+
+test.describe('CRUD proto with all fields', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ test.afterAll(async () => {
+ // cleanup: delete the proto
+ if (createdProtoId) {
+ await e2eReq.delete(`${API_PROTOS}/${createdProtoId}`).catch(() => {
+ // ignore error if proto doesn't exist
+ });
+ }
+ });
+
+ test('should create a proto with all fields', async ({ page }) => {
+ await test.step('navigate to add proto page', async () => {
+ await protosPom.toAdd(page);
+ await protosPom.isAddPage(page);
+ });
+
+ await test.step('fill in all fields', async () => {
+ // Fill Content (ID is auto-generated, proto only has content field)
+ await page.getByLabel('Content').fill(protoContent);
+ });
+
+ await test.step('submit the form', async () => {
+ await page.getByRole('button', { name: 'Add', exact: true }).click();
+
+ // Should redirect to list page after successful creation
+ await protosPom.isIndexPage(page);
+ });
+
+ await test.step('verify proto was created via API', async () => {
+ // Get the list of protos to find the created one
+ const protos = await e2eReq
+ .get<unknown, APISIXType['RespProtoList']>(API_PROTOS)
+ .then((v) => v.data);
+
+ // Find the proto with our content (search for exact package name)
+ const createdProto = protos.list.find((p) =>
+ p.value.content?.includes('package test;')
+ );
+ expect(createdProto).toBeDefined();
+ expect(createdProto?.value.id).toBeDefined();
+ // eslint-disable-next-line playwright/no-conditional-in-test
+ createdProtoId = createdProto?.value.id || '';
+
+ // Verify content matches
+ expect(createdProto?.value.content).toBe(protoContent);
+ });
+ });
+
+ test('should read/view the proto details', async ({ page }) => {
+ await test.step('verify proto can be retrieved via API', async () => {
+ const proto = await e2eReq
+ .get<unknown, APISIXType['RespProtoDetail']>(
+ `${API_PROTOS}/${createdProtoId}`
+ )
+ .then((v) => v.data);
+
+ expect(proto.value?.id).toBe(createdProtoId);
+ expect(proto.value?.content).toBe(protoContent);
+ expect(proto.value?.create_time).toBeDefined();
+ expect(proto.value?.update_time).toBeDefined();
+ });
+
+ await test.step('navigate to proto details page and verify UI', async ()
=> {
+ // Navigate to protos list page first
+ await protosPom.toIndex(page);
+ await protosPom.isIndexPage(page);
+
+ // Find and click the View button for the created proto
+ const row = page.locator('tr').filter({ hasText: createdProtoId });
+ await row.getByRole('button', { name: 'View' }).click();
+
+ // Verify we're on the detail page
+ await protosPom.isDetailPage(page);
+
+ // Verify the content is displayed correctly on the details page
+ const pageContent = await page.textContent('body');
+ expect(pageContent).toContain('package test;');
+ expect(pageContent).toContain('TestMessage');
+ });
+ });
+
+ test('should update the proto with new values', async ({ page }) => {
+ const updatedContent = `syntax = "proto3";
+package test_updated;
+
+message UpdatedTestMessage {
+ string updated_name = 1;
+ int32 updated_age = 2;
+ string email = 3;
+ bool is_active = 4;
+}`;
+
+ await test.step('navigate to proto detail page', async () => {
+ // Should already be on detail page from previous test, but navigate to
be safe
+ await protosPom.toIndex(page);
+ await protosPom.isIndexPage(page);
+
+ const row = page.locator('tr').filter({ hasText: createdProtoId });
+ await row.getByRole('button', { name: 'View' }).click();
+ await protosPom.isDetailPage(page);
+ });
+
+ await test.step('enter edit mode and update content', async () => {
+ // Click Edit button to enter edit mode
+ await page.getByRole('button', { name: 'Edit' }).click();
+
+ // Clear and fill the content field
+ const contentField = page.getByLabel('Content');
+ await contentField.clear();
+ await contentField.fill(updatedContent);
+ });
+
+ await test.step('save the changes', async () => {
+ // Click Save button
+ await page.getByRole('button', { name: 'Save' }).click();
+
+ // Verify we're back in detail view mode
+ await protosPom.isDetailPage(page);
+ });
+
+ await test.step('verify proto was updated', async () => {
+ // Verify the updated content is displayed
+ const pageContent = await page.textContent('body');
+ expect(pageContent).toContain('package test_updated');
+ expect(pageContent).toContain('UpdatedTestMessage');
+
+ // Also verify via API
+ const proto = await e2eReq
+ .get<unknown, APISIXType['RespProtoDetail']>(
+ `${API_PROTOS}/${createdProtoId}`
+ )
+ .then((v) => v.data);
+
+ expect(proto.value?.id).toBe(createdProtoId);
+ expect(proto.value?.content).toBe(updatedContent);
+ });
+ });
+
+ test('should delete the proto', async ({ page }) => {
+ await test.step('navigate to detail page and delete', async () => {
+ // Navigate to protos list page first
+ await protosPom.toIndex(page);
+ await protosPom.isIndexPage(page);
+
+ // Find and click the View button
+ const row = page.locator('tr').filter({ hasText: createdProtoId });
+ await row.getByRole('button', { name: 'View' }).click();
+ await protosPom.isDetailPage(page);
+
+ // Click Delete button
+ await page.getByRole('button', { name: 'Delete' }).click();
+
+ // Confirm deletion in the dialog
+ const deleteDialog = page.getByRole('dialog', { name: 'Delete Proto' });
+ await expect(deleteDialog).toBeVisible();
+ await deleteDialog.getByRole('button', { name: 'Delete' }).click();
+ });
+
+ await test.step('verify deletion and redirect', async () => {
+ // Should redirect to list page after deletion
+ await protosPom.isIndexPage(page);
+
+ // Verify proto is not in the list (check in table cells specifically)
+ await expect(page.getByRole('cell', { name: createdProtoId
})).toBeHidden();
+ });
+
+ await test.step('verify proto was deleted via API', async () => {
+ await expect(async () => {
+ await e2eReq.get(`${API_PROTOS}/${createdProtoId}`);
+ }).rejects.toThrow();
+ });
+ });
+});
diff --git a/e2e/tests/protos.crud-required-fields.spec.ts
b/e2e/tests/protos.crud-required-fields.spec.ts
new file mode 100644
index 000000000..97c741cf7
--- /dev/null
+++ b/e2e/tests/protos.crud-required-fields.spec.ts
@@ -0,0 +1,209 @@
+/**
+ * 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 { protosPom } from '@e2e/pom/protos';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+import { API_PROTOS } from '@/config/constant';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const protoContent = `syntax = "proto3";
+package test_required;
+
+message TestMessageRequired {
+ string name = 1;
+ int32 age = 2;
+}`;
+
+let createdProtoId: string;
+
+test.describe('CRUD proto with required fields only', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ test.afterAll(async () => {
+ // cleanup: delete the proto
+ if (createdProtoId) {
+ await e2eReq.delete(`${API_PROTOS}/${createdProtoId}`).catch(() => {
+ // ignore error if proto doesn't exist
+ });
+ }
+ });
+
+ test('should create a proto with required fields', async ({ page }) => {
+ await test.step('navigate to add proto page', async () => {
+ await protosPom.toAdd(page);
+ await protosPom.isAddPage(page);
+ });
+
+ await test.step('fill in required fields', async () => {
+ // Fill Content (ID is auto-generated)
+ await page.getByLabel('Content').fill(protoContent);
+ });
+
+ await test.step('submit the form', async () => {
+ await page.getByRole('button', { name: 'Add', exact: true }).click();
+
+ // Should redirect to list page after successful creation
+ await protosPom.isIndexPage(page);
+ });
+
+ await test.step('verify proto was created via API', async () => {
+ // Get the list of protos to find the created one
+ const protos = await e2eReq
+ .get<unknown, APISIXType['RespProtoList']>(API_PROTOS)
+ .then((v) => v.data);
+
+ // Find the proto with our content
+ const createdProto = protos.list.find((p) =>
+ p.value.content?.includes('package test_required')
+ );
+ expect(createdProto).toBeDefined();
+ expect(createdProto?.value.id).toBeDefined();
+ // eslint-disable-next-line playwright/no-conditional-in-test
+ createdProtoId = createdProto?.value.id || '';
+
+ // Verify content matches
+ expect(createdProto?.value.content).toBe(protoContent);
+ });
+ });
+
+ test('should read/view the proto details', async ({ page }) => {
+ await test.step('verify proto can be retrieved via API', async () => {
+ const proto = await e2eReq
+ .get<unknown, APISIXType['RespProtoDetail']>(
+ `${API_PROTOS}/${createdProtoId}`
+ )
+ .then((v) => v.data);
+
+ expect(proto.value?.id).toBe(createdProtoId);
+ expect(proto.value?.content).toBe(protoContent);
+ expect(proto.value?.create_time).toBeDefined();
+ expect(proto.value?.update_time).toBeDefined();
+ });
+
+ await test.step('navigate to proto details page and verify UI', async ()
=> {
+ // Navigate to protos list page first
+ await protosPom.toIndex(page);
+ await protosPom.isIndexPage(page);
+
+ // Find and click the View button for the created proto
+ const row = page.locator('tr').filter({ hasText: createdProtoId });
+ await row.getByRole('button', { name: 'View' }).click();
+
+ // Verify we're on the detail page
+ await protosPom.isDetailPage(page);
+
+ // Verify the content is displayed correctly on the details page
+ const pageContent = await page.textContent('body');
+ expect(pageContent).toContain('package test_required');
+ expect(pageContent).toContain('TestMessageRequired');
+ });
+ });
+
+ test('should update the proto', async ({ page }) => {
+ const updatedContent = `syntax = "proto3";
+package test_updated;
+
+message UpdatedTestMessage {
+ string updated_name = 1;
+ int32 updated_age = 2;
+ string email = 3;
+}`;
+
+ await test.step('navigate to proto detail page', async () => {
+ // Should already be on detail page from previous test, but navigate to
be safe
+ await protosPom.toIndex(page);
+ await protosPom.isIndexPage(page);
+
+ const row = page.locator('tr').filter({ hasText: createdProtoId });
+ await row.getByRole('button', { name: 'View' }).click();
+ await protosPom.isDetailPage(page);
+ });
+
+ await test.step('enter edit mode and update content', async () => {
+ // Click Edit button to enter edit mode
+ await page.getByRole('button', { name: 'Edit' }).click();
+
+ // Clear and fill the content field
+ const contentField = page.getByLabel('Content');
+ await contentField.clear();
+ await contentField.fill(updatedContent);
+ });
+
+ await test.step('save the changes', async () => {
+ // Click Save button
+ await page.getByRole('button', { name: 'Save' }).click();
+
+ // Verify we're back in detail view mode
+ await protosPom.isDetailPage(page);
+ });
+
+ await test.step('verify proto was updated', async () => {
+ // Verify the updated content is displayed
+ const pageContent = await page.textContent('body');
+ expect(pageContent).toContain('package test_updated');
+ expect(pageContent).toContain('UpdatedTestMessage');
+
+ // Also verify via API
+ const proto = await e2eReq
+ .get<unknown, APISIXType['RespProtoDetail']>(
+ `${API_PROTOS}/${createdProtoId}`
+ )
+ .then((v) => v.data);
+
+ expect(proto.value?.id).toBe(createdProtoId);
+ expect(proto.value?.content).toBe(updatedContent);
+ });
+ });
+
+ test('should delete the proto', async ({ page }) => {
+ await test.step('navigate to detail page and delete', async () => {
+ // Navigate to protos list page first
+ await protosPom.toIndex(page);
+ await protosPom.isIndexPage(page);
+
+ // Find and click the View button
+ const row = page.locator('tr').filter({ hasText: createdProtoId });
+ await row.getByRole('button', { name: 'View' }).click();
+ await protosPom.isDetailPage(page);
+
+ // Click Delete button
+ await page.getByRole('button', { name: 'Delete' }).click();
+
+ // Confirm deletion in the dialog
+ const deleteDialog = page.getByRole('dialog', { name: 'Delete Proto' });
+ await expect(deleteDialog).toBeVisible();
+ await deleteDialog.getByRole('button', { name: 'Delete' }).click();
+ });
+
+ await test.step('verify deletion and redirect', async () => {
+ // Should redirect to list page after deletion
+ await protosPom.isIndexPage(page);
+
+ // Verify proto is not in the list (check in table cells specifically)
+ await expect(page.getByRole('cell', { name: createdProtoId
})).toBeHidden();
+ });
+
+ await test.step('verify proto was deleted via API', async () => {
+ await expect(async () => {
+ await e2eReq.get(`${API_PROTOS}/${createdProtoId}`);
+ }).rejects.toThrow();
+ });
+ });
+});
diff --git a/e2e/tests/protos.list.spec.ts b/e2e/tests/protos.list.spec.ts
new file mode 100644
index 000000000..2f17897e0
--- /dev/null
+++ b/e2e/tests/protos.list.spec.ts
@@ -0,0 +1,96 @@
+/**
+ * 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 { protosPom } from '@e2e/pom/protos';
+import { setupPaginationTests } from '@e2e/utils/pagination-test-helper';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { expect, type Page } from '@playwright/test';
+
+import { putProtoReq } from '@/apis/protos';
+import { API_PROTOS } from '@/config/constant';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test('should navigate to protos page', async ({ page }) => {
+ await test.step('navigate to protos page', async () => {
+ await protosPom.getProtoNavBtn(page).click();
+ await protosPom.isIndexPage(page);
+ });
+
+ await test.step('verify protos page components', async () => {
+ await expect(protosPom.getAddProtoBtn(page)).toBeVisible();
+
+ // list table exists
+ const table = page.getByRole('table');
+ await expect(table).toBeVisible();
+ await expect(table.getByText('ID', { exact: true })).toBeVisible();
+ await expect(table.getByText('Actions', { exact: true })).toBeVisible();
+ });
+});
+
+const protos: APISIXType['Proto'][] = Array.from({ length: 11 }, (_, i) => ({
+ id: `proto_id_${i + 1}`,
+ desc: `proto_desc_${i + 1}`,
+ content: `syntax = "proto3";
+package test${i + 1};
+
+message TestMessage${i + 1} {
+ string field = 1;
+}`,
+}));
+
+test.describe('page and page_size should work correctly', () => {
+ test.describe.configure({ mode: 'serial' });
+ test.beforeAll(async () => {
+ // Delete all existing protos
+ const existingProtos = await e2eReq
+ .get<unknown, APISIXType['RespProtoList']>(API_PROTOS)
+ .then((v) => v.data);
+ await Promise.all(
+ (existingProtos.list || []).map((d) =>
+ e2eReq.delete(`${API_PROTOS}/${d.value.id}`)
+ )
+ );
+
+ // Create test protos
+ await Promise.all(protos.map((d) => putProtoReq(e2eReq, d)));
+ });
+
+ test.afterAll(async () => {
+ await Promise.all(
+ protos.map((d) => e2eReq.delete(`${API_PROTOS}/${d.id}`))
+ );
+ });
+
+ // Setup pagination tests with proto-specific configurations
+ const filterItemsNotInPage = async (page: Page) => {
+ // filter the item which not in the current page
+ // it should be random, so we need get all items in the table
+ const itemsInPage = await page
+ .getByRole('cell', { name: /proto_id_/ })
+ .all();
+ const ids = await Promise.all(itemsInPage.map((v) => v.textContent()));
+ return protos.filter((d) => !ids.includes(d.id));
+ };
+
+ setupPaginationTests(test, {
+ pom: protosPom,
+ items: protos,
+ filterItemsNotInPage,
+ getCell: (page, item) => page.getByRole('cell', { name: item.id }).first(),
+ });
+});