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,