This is an automated email from the ASF dual-hosted git repository.

baoyuan 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 a2ee27a2a test(e2e): add comprehensive service CRUD tests and UI 
utility functions (#3258)
a2ee27a2a is described below

commit a2ee27a2a0ef786c5d925de96f37e2ddac5d73c9
Author: Deep Shekhar Singh <[email protected]>
AuthorDate: Mon Dec 29 14:10:20 2025 +0530

    test(e2e): add comprehensive service CRUD tests and UI utility functions 
(#3258)
---
 e2e/tests/plugin_metadata.crud-all-fields.spec.ts  |  13 +-
 ...ds.spec.ts => services.crud-all-fields.spec.ts} |  85 +++++----
 e2e/tests/services.crud-required-fields.spec.ts    | 182 +++++++++++++++++++
 e2e/tests/services.list.spec.ts                    |  93 ++++++++++
 e2e/tests/services.routes.crud.spec.ts             |   2 +
 e2e/tests/services.routes.list.spec.ts             |   2 +
 e2e/tests/services.stream_routes.crud.spec.ts      |   2 +
 e2e/tests/services.stream_routes.list.spec.ts      |   2 +
 .../stream_routes.show-disabled-error.spec.ts      |  13 +-
 e2e/tests/upstreams.crud-all-fields.spec.ts        |   1 +
 e2e/utils/ui/services.ts                           | 195 +++++++++++++++++++++
 src/apis/services.ts                               |   7 +
 12 files changed, 546 insertions(+), 51 deletions(-)

diff --git a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts 
b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
index 4f495bc15..907c530c5 100644
--- a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
+++ b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
@@ -34,15 +34,26 @@ const deletePluginMetadata = async (req: typeof e2eReq, 
name: string) => {
   });
 };
 const getMonacoEditorValue = async (editPluginDialog: Locator) => {
-  let editorValue = '';
   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();
     console.log('DEBUG: editorValue fallback failed, dialog text:', allText);
diff --git a/e2e/tests/upstreams.crud-all-fields.spec.ts 
b/e2e/tests/services.crud-all-fields.spec.ts
similarity index 53%
copy from e2e/tests/upstreams.crud-all-fields.spec.ts
copy to e2e/tests/services.crud-all-fields.spec.ts
index 213172d51..101660bd7 100644
--- a/e2e/tests/upstreams.crud-all-fields.spec.ts
+++ b/e2e/tests/services.crud-all-fields.spec.ts
@@ -14,40 +14,40 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { upstreamsPom } from '@e2e/pom/upstreams';
+import { servicesPom } from '@e2e/pom/services';
 import { randomId } from '@e2e/utils/common';
 import { e2eReq } from '@e2e/utils/req';
 import { test } from '@e2e/utils/test';
 import { uiHasToastMsg } from '@e2e/utils/ui';
 import {
-  uiCheckUpstreamAllFields,
-  uiFillUpstreamAllFields,
-} from '@e2e/utils/ui/upstreams';
+  uiCheckServiceAllFields,
+  uiFillServiceAllFields,
+} from '@e2e/utils/ui/services';
 import { expect } from '@playwright/test';
 
-import { deleteAllUpstreams } from '@/apis/upstreams';
+import { deleteAllServices } from '@/apis/services';
+
+test.describe.configure({ mode: 'serial' });
 
 test.beforeAll(async () => {
-  await deleteAllUpstreams(e2eReq);
+  await deleteAllServices(e2eReq);
 });
 
-test('should CRUD upstream with all fields', async ({ page }) => {
-  test.setTimeout(30000);
-
-  const upstreamNameWithAllFields = randomId('test-upstream-full');
+test('should CRUD service with all fields', async ({ page }) => {
+  const serviceNameWithAllFields = randomId('test-service-full');
   const description =
-    'This is a test description for the upstream with all fields';
+    'This is a test description for the service with all fields';
 
-  // Navigate to the upstream list page
-  await upstreamsPom.toIndex(page);
-  await upstreamsPom.isIndexPage(page);
+  // Navigate to the service list page
+  await servicesPom.toIndex(page);
+  await servicesPom.isIndexPage(page);
 
-  // Click the add upstream button
-  await upstreamsPom.getAddUpstreamBtn(page).click();
-  await upstreamsPom.isAddPage(page);
+  // Click the add service button
+  await servicesPom.getAddServiceBtn(page).click();
+  await servicesPom.isAddPage(page);
 
-  await uiFillUpstreamAllFields(test, page, {
-    name: upstreamNameWithAllFields,
+  await uiFillServiceAllFields(test, page, {
+    name: serviceNameWithAllFields,
     desc: description,
   });
 
@@ -57,67 +57,64 @@ test('should CRUD upstream with all fields', async ({ page 
}) => {
 
   // Wait for success message
   await uiHasToastMsg(page, {
-    hasText: 'Add Upstream Successfully',
+    hasText: 'Add Service Successfully',
   });
 
   // Verify automatic redirection to detail page
-  await upstreamsPom.isDetailPage(page);
+  await servicesPom.isDetailPage(page);
 
   await test.step('verify all fields in detail page', async () => {
-    await uiCheckUpstreamAllFields(page, {
-      name: upstreamNameWithAllFields,
+    await uiCheckServiceAllFields(page, {
+      name: serviceNameWithAllFields,
       desc: description,
     });
   });
 
   await test.step('return to list page and verify', async () => {
-    // Return to the upstream list page
-    await upstreamsPom.getUpstreamNavBtn(page).click();
-    await upstreamsPom.isIndexPage(page);
+    // Return to the service list page
+    await servicesPom.getServiceNavBtn(page).click();
+    await servicesPom.isIndexPage(page);
 
-    // Verify the created upstream is visible in the list - using a more 
reliable method
-    // Using expect's toBeVisible method which has a retry mechanism
+    // Verify the created service is visible in the list
     await expect(page.locator('.ant-table-tbody')).toBeVisible();
 
-    // Use expect to wait for the upstream name to appear
-    await expect(page.getByText(upstreamNameWithAllFields)).toBeVisible();
+    // Use expect to wait for the service name to appear
+    await expect(page.getByText(serviceNameWithAllFields)).toBeVisible();
   });
 
-  await test.step('delete the created upstream', async () => {
-    // Find the row containing the upstream name
-    const row = page
-      .locator('tr')
-      .filter({ hasText: upstreamNameWithAllFields });
+  await test.step('delete the created service', async () => {
+    // Find the row containing the service name
+    const row = page.locator('tr').filter({ hasText: serviceNameWithAllFields 
});
     await expect(row).toBeVisible();
 
     // Click to view details
     await row.getByRole('button', { name: 'View' }).click();
 
     // Verify entered detail page
-    await upstreamsPom.isDetailPage(page);
+    await servicesPom.isDetailPage(page);
 
-    // Delete the upstream
+    // Delete the service
     await page.getByRole('button', { name: 'Delete' }).click();
 
     // Confirm deletion
-    const deleteDialog = page.getByRole('dialog', { name: 'Delete Upstream' });
+    const deleteDialog = page.getByRole('dialog', { name: 'Delete Service' });
     await expect(deleteDialog).toBeVisible();
     await deleteDialog.getByRole('button', { name: 'Delete' }).click();
 
     // Verify successful deletion
-    await upstreamsPom.isIndexPage(page);
+    await servicesPom.isIndexPage(page);
     await uiHasToastMsg(page, {
-      hasText: 'Delete Upstream Successfully',
+      hasText: 'Delete Service Successfully',
     });
 
     // Verify removed from the list
-    await expect(page.getByText(upstreamNameWithAllFields)).toBeHidden();
+    await expect(page.getByText(serviceNameWithAllFields)).toBeHidden();
 
     // Final verification: Reload the page and check again to ensure it's 
really gone
     await page.reload();
-    await upstreamsPom.isIndexPage(page);
+    await servicesPom.isIndexPage(page);
 
-    // After reload, the upstream should still be gone
-    await expect(page.getByText(upstreamNameWithAllFields)).toBeHidden();
+    // After reload, the service should still be gone
+    await expect(page.getByText(serviceNameWithAllFields)).toBeHidden();
   });
 });
diff --git a/e2e/tests/services.crud-required-fields.spec.ts 
b/e2e/tests/services.crud-required-fields.spec.ts
new file mode 100644
index 000000000..f0f17b3ee
--- /dev/null
+++ b/e2e/tests/services.crud-required-fields.spec.ts
@@ -0,0 +1,182 @@
+/**
+ * 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 { servicesPom } from '@e2e/pom/services';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import {
+  uiCheckServiceRequiredFields,
+  uiFillServiceRequiredFields,
+} from '@e2e/utils/ui/services';
+import { expect } from '@playwright/test';
+
+import { deleteAllServices } from '@/apis/services';
+
+test.describe.configure({ mode: 'serial' });
+
+const serviceName = randomId('test-service');
+
+test.beforeAll(async () => {
+  await deleteAllServices(e2eReq);
+});
+
+test('should CRUD service with required fields', async ({ page }) => {
+  await servicesPom.toIndex(page);
+  await servicesPom.isIndexPage(page);
+
+  await servicesPom.getAddServiceBtn(page).click();
+  await servicesPom.isAddPage(page);
+  await test.step('submit with required fields', async () => {
+    await uiFillServiceRequiredFields(page, {
+      name: serviceName,
+    });
+
+    // Ensure upstream is valid. In some configurations (e.g. http&stream), 
+    // the backend might require a valid upstream configuration.
+    const upstreamSection = page.getByRole('group', { name: 'Upstream' 
}).first();
+    const addNodeBtn = page.getByRole('button', { name: 'Add a Node' });
+    await addNodeBtn.click();
+
+    const rows = upstreamSection.locator('tr.ant-table-row');
+    await rows.first().locator('input').first().fill('127.0.0.1');
+    await rows.first().locator('input').nth(1).fill('80');
+    await rows.first().locator('input').nth(2).fill('1');
+
+    // Ensure the name field is properly filled before submitting
+    const nameField = page.getByRole('textbox', { name: 'Name' }).first();
+    await expect(nameField).toHaveValue(serviceName);
+
+    await servicesPom.getAddBtn(page).click();
+
+    // Wait for either success or error toast (longer timeout for CI)
+    const alertMsg = page.getByRole('alert');
+    await expect(alertMsg).toBeVisible({ timeout: 30000 });
+
+    // Check if it's a success message
+    await expect(alertMsg).toContainText('Add Service Successfully', { 
timeout: 5000 });
+
+    // Close the toast
+    await alertMsg.getByRole('button').click();
+    await expect(alertMsg).toBeHidden();
+  });
+
+  await test.step('auto navigate to service detail page', async () => {
+    await servicesPom.isDetailPage(page);
+    // Verify ID exists
+    const ID = page.getByRole('textbox', { name: 'ID', exact: true });
+    await expect(ID).toBeVisible();
+    await expect(ID).toBeDisabled();
+    await uiCheckServiceRequiredFields(page, {
+      name: serviceName,
+    });
+  });
+
+  await test.step('can see service in list page', async () => {
+    await servicesPom.getServiceNavBtn(page).click();
+    await expect(page.getByRole('cell', { name: serviceName })).toBeVisible();
+  });
+
+  await test.step('navigate to service detail page', async () => {
+    // Click on the service name to go to the detail page
+    await page
+      .getByRole('row', { name: serviceName })
+      .getByRole('button', { name: 'View' })
+      .click();
+    await servicesPom.isDetailPage(page);
+    const name = page.getByRole('textbox', { name: 'Name' }).first();
+    await expect(name).toHaveValue(serviceName);
+  });
+
+  await test.step('edit and update service in detail page', async () => {
+    // Click the Edit button in the detail page
+    await page.getByRole('button', { name: 'Edit' }).click();
+
+    // Verify we're in edit mode - fields should be editable now
+    const nameField = page.getByRole('textbox', { name: 'Name' }).first();
+    await expect(nameField).toBeEnabled();
+
+    // Update the description field (use first() to get service description, 
not upstream description)
+    const descriptionField = page.getByLabel('Description').first();
+    await descriptionField.fill('Updated description for testing');
+
+    // Add a simple label (key:value format)
+    // Use first() to get service labels field, not upstream labels
+    const labelsField = page.getByPlaceholder('Input text like 
`key:value`,').first();
+    await expect(labelsField).toBeEnabled();
+
+    // Add a single label in key:value format
+    await labelsField.click();
+    await labelsField.fill('version:v1');
+    await labelsField.press('Enter');
+
+    // Verify the label was added by checking if the input is cleared
+    // This indicates the tag was successfully created
+    await expect(labelsField).toHaveValue('');
+
+    // Click the Save button to save changes
+    const saveBtn = page.getByRole('button', { name: 'Save' });
+    await saveBtn.click();
+
+    // Verify the update was successful
+    await uiHasToastMsg(page, {
+      hasText: 'success',
+    });
+
+    // Verify we're back in detail view mode
+    await servicesPom.isDetailPage(page);
+
+    // Verify the updated fields
+    await expect(page.getByLabel('Description').first()).toHaveValue(
+      'Updated description for testing'
+    );
+
+    // check labels
+    await expect(page.getByText('version:v1')).toBeVisible();
+
+    // Return to list page and verify the service exists
+    await servicesPom.getServiceNavBtn(page).click();
+    await servicesPom.isIndexPage(page);
+
+    // Find the row with our service
+    const row = page.getByRole('row', { name: serviceName });
+    await expect(row).toBeVisible();
+  });
+
+  await test.step('delete service in detail page', async () => {
+    // Navigate back to detail page
+    await page
+      .getByRole('row', { name: serviceName })
+      .getByRole('button', { name: 'View' })
+      .click();
+    await servicesPom.isDetailPage(page);
+
+    await page.getByRole('button', { name: 'Delete' }).click();
+
+    await page
+      .getByRole('dialog', { name: 'Delete Service' })
+      .getByRole('button', { name: 'Delete' })
+      .click();
+
+    // will redirect to services page
+    await servicesPom.isIndexPage(page);
+    await uiHasToastMsg(page, {
+      hasText: 'Delete Service Successfully',
+    });
+    await expect(page.getByRole('cell', { name: serviceName })).toBeHidden();
+  });
+});
diff --git a/e2e/tests/services.list.spec.ts b/e2e/tests/services.list.spec.ts
new file mode 100644
index 000000000..ceb397295
--- /dev/null
+++ b/e2e/tests/services.list.spec.ts
@@ -0,0 +1,93 @@
+/**
+ * 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 { servicesPom } from '@e2e/pom/services';
+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 { deleteAllServices } from '@/apis/services';
+import { API_SERVICES } from '@/config/constant';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test.describe.configure({ mode: 'serial' });
+
+test('should navigate to services page', async ({ page }) => {
+  await test.step('navigate to services page', async () => {
+    await servicesPom.getServiceNavBtn(page).click();
+    await servicesPom.isIndexPage(page);
+  });
+
+  await test.step('verify services page components', async () => {
+    await expect(servicesPom.getAddServiceBtn(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('Name', { exact: true })).toBeVisible();
+    await expect(table.getByText('Actions', { exact: true })).toBeVisible();
+  });
+});
+
+const services: APISIXType['Service'][] = Array.from({ length: 11 }, (_, i) => 
({
+  id: `service_id_${i + 1}`,
+  name: `service_name_${i + 1}`,
+  desc: `Service description ${i + 1}`,
+  create_time: Date.now(),
+  update_time: Date.now(),
+}));
+
+test.describe('page and page_size should work correctly', () => {
+  test.describe.configure({ mode: 'serial' });
+  test.beforeAll(async () => {
+    await deleteAllServices(e2eReq);
+    await Promise.all(
+      services.map((d) => {
+        // eslint-disable-next-line @typescript-eslint/no-unused-vars
+        const { id, create_time: _createTime, update_time: _updateTime, 
...rest } = d;
+        return e2eReq.put(`${API_SERVICES}/${id}`, rest);
+      })
+    );
+  });
+
+  test.afterAll(async () => {
+    await Promise.all(
+      services.map((d) => e2eReq.delete(`${API_SERVICES}/${d.id}`))
+    );
+  });
+
+  // Setup pagination tests with service-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: /service_name_/ })
+      .all();
+    const names = await Promise.all(itemsInPage.map((v) => v.textContent()));
+    return services.filter((d) => !names.includes(d.name));
+  };
+
+  setupPaginationTests(test, {
+    pom: servicesPom,
+    items: services,
+    filterItemsNotInPage,
+    getCell: (page, item) =>
+      page.getByRole('cell', { name: item.name }).first(),
+  });
+});
diff --git a/e2e/tests/services.routes.crud.spec.ts 
b/e2e/tests/services.routes.crud.spec.ts
index 16e6480db..181741a2c 100644
--- a/e2e/tests/services.routes.crud.spec.ts
+++ b/e2e/tests/services.routes.crud.spec.ts
@@ -26,6 +26,8 @@ import { deleteAllRoutes } from '@/apis/routes';
 import { deleteAllServices, postServiceReq } from '@/apis/services';
 import type { APISIXType } from '@/types/schema/apisix';
 
+test.describe.configure({ mode: 'serial' });
+
 const serviceName = randomId('test-service');
 const routeName = randomId('test-route');
 const routeUri = '/test-route';
diff --git a/e2e/tests/services.routes.list.spec.ts 
b/e2e/tests/services.routes.list.spec.ts
index abb6b0679..56f2afc15 100644
--- a/e2e/tests/services.routes.list.spec.ts
+++ b/e2e/tests/services.routes.list.spec.ts
@@ -25,6 +25,8 @@ import { deleteAllRoutes, postRouteReq } from '@/apis/routes';
 import { deleteAllServices, postServiceReq } from '@/apis/services';
 import type { APISIXType } from '@/types/schema/apisix';
 
+test.describe.configure({ mode: 'serial' });
+
 const serviceName = randomId('test-service');
 const anotherServiceName = randomId('another-service');
 const routes: APISIXType['Route'][] = [
diff --git a/e2e/tests/services.stream_routes.crud.spec.ts 
b/e2e/tests/services.stream_routes.crud.spec.ts
index a53f641b3..cb169e333 100644
--- a/e2e/tests/services.stream_routes.crud.spec.ts
+++ b/e2e/tests/services.stream_routes.crud.spec.ts
@@ -24,6 +24,8 @@ import { expect } from '@playwright/test';
 import { deleteAllServices, postServiceReq } from '@/apis/services';
 import { deleteAllStreamRoutes } from '@/apis/stream_routes';
 
+test.describe.configure({ mode: 'serial' });
+
 const serviceName = randomId('test-service');
 const streamRouteServerAddr = '127.0.0.1';
 const streamRouteServerPort = 8080;
diff --git a/e2e/tests/services.stream_routes.list.spec.ts 
b/e2e/tests/services.stream_routes.list.spec.ts
index 8158ff015..d16b637c3 100644
--- a/e2e/tests/services.stream_routes.list.spec.ts
+++ b/e2e/tests/services.stream_routes.list.spec.ts
@@ -27,6 +27,8 @@ import {
   postStreamRouteReq,
 } from '@/apis/stream_routes';
 
+test.describe.configure({ mode: 'serial' });
+
 const serviceName = randomId('test-service');
 const anotherServiceName = randomId('another-service');
 const streamRoutes = [
diff --git a/e2e/tests/stream_routes.show-disabled-error.spec.ts 
b/e2e/tests/stream_routes.show-disabled-error.spec.ts
index 1b4d8abac..24ee23bc8 100644
--- a/e2e/tests/stream_routes.show-disabled-error.spec.ts
+++ b/e2e/tests/stream_routes.show-disabled-error.spec.ts
@@ -96,13 +96,14 @@ test.afterAll(async () => {
 test('show disabled error', async ({ page }) => {
   await streamRoutesPom.toIndex(page);
 
-  await expect(page.locator('main > span')).toContainText(
-    'stream mode is disabled, can not add stream routes'
-  );
+  // Wait for the error message to appear (extra long timeout for CI after 
server restart)
+  await expect(
+    page.getByText('stream mode is disabled, can not add stream routes')
+  ).toBeVisible({ timeout: 30000 });
 
   // Verify the error message is still shown after refresh
   await page.reload();
-  await expect(page.locator('main > span')).toContainText(
-    'stream mode is disabled, can not add stream routes'
-  );
+  await expect(
+    page.getByText('stream mode is disabled, can not add stream routes')
+  ).toBeVisible({ timeout: 30000 });
 });
diff --git a/e2e/tests/upstreams.crud-all-fields.spec.ts 
b/e2e/tests/upstreams.crud-all-fields.spec.ts
index 213172d51..61e352275 100644
--- a/e2e/tests/upstreams.crud-all-fields.spec.ts
+++ b/e2e/tests/upstreams.crud-all-fields.spec.ts
@@ -115,6 +115,7 @@ test('should CRUD upstream with all fields', async ({ page 
}) => {
 
     // Final verification: Reload the page and check again to ensure it's 
really gone
     await page.reload();
+    await page.waitForLoadState('load');
     await upstreamsPom.isIndexPage(page);
 
     // After reload, the upstream should still be gone
diff --git a/e2e/utils/ui/services.ts b/e2e/utils/ui/services.ts
new file mode 100644
index 000000000..dae635bb5
--- /dev/null
+++ b/e2e/utils/ui/services.ts
@@ -0,0 +1,195 @@
+/**
+ * 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 type { Locator, Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+
+import type { APISIXType } from '@/types/schema/apisix';
+
+import type { Test } from '../test';
+
+/**
+ * Fill the service form with required fields
+ * Note: Services have no strictly required fields, but name is commonly used
+ */
+export async function uiFillServiceRequiredFields(
+  ctx: Page | Locator,
+  service: Partial<APISIXType['Service']>
+) {
+  // Fill in the Service Name field (not the upstream name)
+  // Use a more specific selector to avoid conflicts with upstream.name
+  const nameField = (ctx as Page).getByRole('textbox', { name: 'Name' 
}).first();
+  await nameField.fill(service.name);
+}
+
+export async function uiCheckServiceRequiredFields(
+  ctx: Page | Locator,
+  service: Partial<APISIXType['Service']>
+) {
+  // Verify the service name (not the upstream name)
+  const name = (ctx as Page).getByRole('textbox', { name: 'Name' }).first();
+  await expect(name).toHaveValue(service.name);
+  await expect(name).toBeDisabled();
+}
+
+export async function uiFillServiceAllFields(
+  test: Test,
+  ctx: Page | Locator,
+  service: Partial<APISIXType['Service']>
+) {
+  await test.step('fill in basic fields', async () => {
+    // 1. Name - use first() to get service name, not upstream name
+    await (ctx as Page).getByRole('textbox', { name: 'Name' 
}).first().fill(service.name);
+
+    // 2. Description - use first() to get service description, not upstream 
description
+    await ctx.getByLabel('Description').first().fill(service.desc);
+
+    // 3. Labels - use placeholder to get service labels field, not upstream 
labels
+    const labelsField = (ctx as Page).getByPlaceholder('Input text like 
`key:value`,').first();
+    await expect(labelsField).toBeEnabled();
+    await labelsField.click();
+    await labelsField.fill('env:production');
+    await labelsField.press('Enter');
+    await labelsField.fill('version:v1');
+    await labelsField.press('Enter');
+    await expect(labelsField).toHaveValue('');
+  });
+
+  await test.step('fill in upstream configuration', async () => {
+    // Configure upstream
+    const upstreamSection = ctx
+      .getByRole('group', { name: 'Upstream' })
+      .first();
+    
+    // Add nodes
+    const addNodeBtn = ctx.getByRole('button', { name: 'Add a Node' });
+    const noData = upstreamSection.getByText('No Data');
+    await expect(noData).toBeVisible();
+
+    // Add first node
+    await addNodeBtn.click();
+    await expect(noData).toBeHidden();
+    const rows = upstreamSection.locator('tr.ant-table-row');
+    await expect(rows.first()).toBeVisible();
+
+    const hostInput = rows.first().locator('input').first();
+    await hostInput.click();
+    await hostInput.fill('service-node1.example.com');
+    await expect(hostInput).toHaveValue('service-node1.example.com');
+
+    const portInput = rows.first().locator('input').nth(1);
+    await portInput.click();
+    await portInput.fill('8080');
+    await expect(portInput).toHaveValue('8080');
+
+    const weightInput = rows.first().locator('input').nth(2);
+    await weightInput.click();
+    await weightInput.fill('100');
+    await expect(weightInput).toHaveValue('100');
+
+    // Add second node
+    await upstreamSection.click();
+    await addNodeBtn.click();
+    await expect(rows.nth(1)).toBeVisible();
+
+    const hostInput2 = rows.nth(1).locator('input').first();
+    await hostInput2.click();
+    await hostInput2.fill('service-node2.example.com');
+    await expect(hostInput2).toHaveValue('service-node2.example.com');
+
+    const portInput2 = rows.nth(1).locator('input').nth(1);
+    await portInput2.click();
+    await portInput2.fill('8081');
+    await expect(portInput2).toHaveValue('8081');
+
+    const weightInput2 = rows.nth(1).locator('input').nth(2);
+    await weightInput2.click();
+    await weightInput2.fill('50');
+    await expect(weightInput2).toHaveValue('50');
+  });
+
+  await test.step('fill in additional fields', async () => {
+    // 5. Enable WebSocket
+    const websocketSwitchInput = ctx
+      .locator('input[name="enable_websocket"]')
+      .first();
+    await websocketSwitchInput.evaluate((el) => {
+      (el as HTMLElement).click();
+    });
+    await expect(websocketSwitchInput).toBeChecked();
+
+    // 6. Hosts
+    const hostsField = ctx.getByRole('textbox', { name: 'Hosts' });
+    await expect(hostsField).toBeEnabled();
+    await hostsField.click();
+    await hostsField.fill('api.example.com');
+    await hostsField.press('Enter');
+    await hostsField.fill('www.example.com');
+    await hostsField.press('Enter');
+    await expect(hostsField).toHaveValue('');
+  });
+}
+
+export async function uiCheckServiceAllFields(
+  ctx: Page | Locator,
+  service: Partial<APISIXType['Service']>
+) {
+  // Verify basic information - use first() to get service name, not upstream 
name
+  const name = (ctx as Page).getByRole('textbox', { name: 'Name' }).first();
+  await expect(name).toHaveValue(service.name);
+  await expect(name).toBeDisabled();
+
+  const descriptionField = ctx.getByLabel('Description').first();
+  await expect(descriptionField).toHaveValue(service.desc);
+  await expect(descriptionField).toBeDisabled();
+
+  // Verify labels
+  await expect(ctx.getByText('env:production')).toBeVisible();
+  await expect(ctx.getByText('version:v1')).toBeVisible();
+
+  // Verify upstream nodes
+  const upstreamSection = ctx
+    .getByRole('group', { name: 'Upstream' })
+    .first();
+  await expect(
+    upstreamSection.getByRole('cell', { name: 'service-node1.example.com' })
+  ).toBeVisible();
+  await expect(
+    upstreamSection.getByRole('cell', { name: '8080' })
+  ).toBeVisible();
+  await expect(
+    upstreamSection.getByRole('cell', { name: '100', exact: true })
+  ).toBeVisible();
+
+  await expect(
+    upstreamSection.getByRole('cell', { name: 'service-node2.example.com' })
+  ).toBeVisible();
+  await expect(
+    upstreamSection.getByRole('cell', { name: '8081' })
+  ).toBeVisible();
+  await expect(
+    upstreamSection.getByRole('cell', { name: '50', exact: true })
+  ).toBeVisible();
+
+  // Verify WebSocket is enabled
+  const websocketSwitch = ctx
+    .locator('input[name="enable_websocket"]').first();
+  await expect(websocketSwitch).toBeChecked();
+
+  // Verify hosts
+  await expect(ctx.getByText('api.example.com')).toBeVisible();
+  await expect(ctx.getByText('www.example.com')).toBeVisible();
+}
diff --git a/src/apis/services.ts b/src/apis/services.ts
index e8ed19f24..4c0d5c3af 100644
--- a/src/apis/services.ts
+++ b/src/apis/services.ts
@@ -20,6 +20,9 @@ import { API_SERVICES, PAGE_SIZE_MAX, PAGE_SIZE_MIN } from 
'@/config/constant';
 import type { APISIXType } from '@/types/schema/apisix';
 import type { PageSearchType } from '@/types/schema/pageSearch';
 
+import { deleteAllRoutes } from './routes';
+import { deleteAllStreamRoutes } from './stream_routes';
+
 export type ServicePostType = APISIXType['ServicePost'];
 
 export const getServiceListReq = (req: AxiosInstance, params: PageSearchType) 
=>
@@ -52,6 +55,10 @@ export const postServiceReq = (req: AxiosInstance, data: 
ServicePostType) =>
   );
 
 export const deleteAllServices = async (req: AxiosInstance) => {
+  // Delete all routes and stream routes first to avoid foreign key constraints
+  await deleteAllRoutes(req);
+  await deleteAllStreamRoutes(req);
+
   const totalRes = await getServiceListReq(req, {
     page: 1,
     page_size: PAGE_SIZE_MIN,

Reply via email to