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(),
+  });
+});

Reply via email to