You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by ju...@apache.org on 2025/06/13 02:22:35 UTC
(apisix-dashboard) branch master updated: test: routes in services (#3112)
This is an automated email from the ASF dual-hosted git repository.
juzhiyuan 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 20cd07df3 test: routes in services (#3112)
20cd07df3 is described below
commit 20cd07df3c801f2992e8aa17eb95b5f87692c69d
Author: YYYoung <is...@outlook.com>
AuthorDate: Fri Jun 13 10:22:30 2025 +0800
test: routes in services (#3112)
---
e2e/pom/services.ts | 41 ++++++
e2e/tests/services.routes.crud.spec.ts | 220 ++++++++++++++++++++++++++++++
e2e/tests/services.routes.list.spec.ts | 239 +++++++++++++++++++++++++++++++++
e2e/utils/ui/index.ts | 14 +-
4 files changed, 512 insertions(+), 2 deletions(-)
diff --git a/e2e/pom/services.ts b/e2e/pom/services.ts
index b3b4685d1..7ac701cf6 100644
--- a/e2e/pom/services.ts
+++ b/e2e/pom/services.ts
@@ -24,6 +24,11 @@ const locator = {
page.getByRole('button', { name: 'Add Service', exact: true }),
getAddBtn: (page: Page) =>
page.getByRole('button', { name: 'Add', exact: true }),
+ // Service routes locators
+ getServiceRoutesTab: (page: Page) =>
+ page.getByRole('tab', { name: 'Routes', exact: true }),
+ getAddRouteBtn: (page: Page) =>
+ page.getByRole('button', { name: 'Add Route', exact: true }),
};
const assert = {
@@ -46,11 +51,47 @@ const assert = {
const title = page.getByRole('heading', { name: 'Service Detail' });
await expect(title).toBeVisible();
},
+ // Service routes assertions
+ isServiceRoutesPage: async (page: Page) => {
+ await expect(page).toHaveURL(
+ (url) =>
+ url.pathname.includes('/services/detail') &&
+ url.pathname.includes('/routes')
+ );
+ // Wait for page to load completely
+ await page.waitForLoadState('networkidle');
+ const title = page.getByRole('heading', { name: 'Routes' });
+ await expect(title).toBeVisible();
+ },
+ isServiceRouteAddPage: async (page: Page) => {
+ await expect(page).toHaveURL(
+ (url) =>
+ url.pathname.includes('/services/detail') &&
+ url.pathname.includes('/routes/add')
+ );
+ const title = page.getByRole('heading', { name: 'Add Route' });
+ await expect(title).toBeVisible();
+ },
+ isServiceRouteDetailPage: async (page: Page) => {
+ await expect(page).toHaveURL(
+ (url) =>
+ url.pathname.includes('/services/detail') &&
+ url.pathname.includes('/routes/detail')
+ );
+ const title = page.getByRole('heading', { name: 'Route Detail' });
+ await expect(title).toBeVisible();
+ },
};
const goto = {
toIndex: (page: Page) => uiGoto(page, '/services'),
toAdd: (page: Page) => uiGoto(page, '/services/add'),
+ toServiceRoutes: (page: Page, serviceId: string) =>
+ uiGoto(page, '/services/detail/$id/routes', { id: serviceId }),
+ toServiceRouteAdd: (page: Page, serviceId: string) =>
+ uiGoto(page, '/services/detail/$id/routes/add', {
+ id: serviceId,
+ }),
};
export const servicesPom = {
diff --git a/e2e/tests/services.routes.crud.spec.ts b/e2e/tests/services.routes.crud.spec.ts
new file mode 100644
index 000000000..16e6480db
--- /dev/null
+++ b/e2e/tests/services.routes.crud.spec.ts
@@ -0,0 +1,220 @@
+/**
+ * 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 { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+import { deleteAllServices, postServiceReq } from '@/apis/services';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const serviceName = randomId('test-service');
+const routeName = randomId('test-route');
+const routeUri = '/test-route';
+const nodes: APISIXType['UpstreamNode'][] = [
+ { host: 'test.com', port: 80, weight: 100 },
+ { host: 'test2.com', port: 80, weight: 100 },
+];
+
+let testServiceId: string;
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+ await deleteAllServices(e2eReq);
+
+ // Create a test service for testing service routes
+ const serviceResponse = await postServiceReq(e2eReq, {
+ name: serviceName,
+ desc: 'Test service for route testing',
+ });
+
+ testServiceId = serviceResponse.data.value.id;
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+ await deleteAllServices(e2eReq);
+});
+
+test('should CRUD route under service with required fields', async ({
+ page,
+}) => {
+ // Navigate to service detail page
+ await servicesPom.toIndex(page);
+ await servicesPom.isIndexPage(page);
+
+ // Click on the service to go to detail page
+ await page
+ .getByRole('row', { name: serviceName })
+ .getByRole('button', { name: 'View' })
+ .click();
+ await servicesPom.isDetailPage(page);
+
+ // Navigate to Routes tab
+ await servicesPom.getServiceRoutesTab(page).click();
+ await servicesPom.isServiceRoutesPage(page);
+
+ await servicesPom.getAddRouteBtn(page).click();
+ await servicesPom.isServiceRouteAddPage(page);
+
+ await test.step('cannot submit without required fields', async () => {
+ await servicesPom.getAddBtn(page).click();
+ await servicesPom.isServiceRouteAddPage(page);
+ await uiHasToastMsg(page, {
+ hasText: 'invalid configuration',
+ });
+ });
+
+ await test.step('submit with required fields', async () => {
+ // Fill in the Name field
+ await page.getByLabel('Name', { exact: true }).first().fill(routeName);
+ await page.getByLabel('URI', { exact: true }).fill(routeUri);
+
+ // Select HTTP method
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+
+ // Verify service_id is pre-filled and disabled (since it's read-only in service context)
+ const serviceIdField = page.getByLabel('Service ID', { exact: true });
+ await expect(serviceIdField).toHaveValue(testServiceId);
+ await expect(serviceIdField).toBeDisabled();
+
+ // Add upstream nodes
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(upstreamSection, {
+ nodes,
+ name: 'test-upstream',
+ desc: 'test',
+ });
+
+ // Submit the form
+ await servicesPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, {
+ hasText: 'Add Route Successfully',
+ });
+ });
+
+ await test.step('auto navigate to route detail page', async () => {
+ await servicesPom.isServiceRouteDetailPage(page);
+
+ // Verify the route details
+ // Verify ID exists
+ const ID = page.getByRole('textbox', { name: 'ID', exact: true });
+ await expect(ID).toBeVisible();
+ await expect(ID).toBeDisabled();
+
+ // Verify the route name
+ const name = page.getByLabel('Name', { exact: true }).first();
+ await expect(name).toHaveValue(routeName);
+ await expect(name).toBeDisabled();
+
+ // Verify the route URI
+ const uri = page.getByLabel('URI', { exact: true });
+ await expect(uri).toHaveValue(routeUri);
+ await expect(uri).toBeDisabled();
+
+ // Verify service_id is still pre-filled and disabled
+ const serviceIdField = page.getByLabel('Service ID', { exact: true });
+ await expect(serviceIdField).toHaveValue(testServiceId);
+ await expect(serviceIdField).toBeDisabled();
+ });
+
+ await test.step('edit and update route 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.getByLabel('Name', { exact: true }).first();
+ await expect(nameField).toBeEnabled();
+
+ // Service ID should still be disabled even in edit mode
+ const serviceIdField = page.getByLabel('Service ID', { exact: true });
+ await expect(serviceIdField).toBeDisabled();
+
+ // Update the description field
+ const descriptionField = page.getByLabel('Description').first();
+ await descriptionField.fill('Updated description for testing');
+
+ // Update URI
+ const uriField = page.getByLabel('URI', { exact: true });
+ await uriField.fill(`${routeUri}-updated`);
+
+ // 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.isServiceRouteDetailPage(page);
+
+ // Verify the updated fields
+ // Verify description
+ await expect(page.getByLabel('Description').first()).toHaveValue(
+ 'Updated description for testing'
+ );
+
+ // Check if the updated URI is visible
+ await expect(page.getByLabel('URI', { exact: true })).toHaveValue(
+ `${routeUri}-updated`
+ );
+ });
+
+ await test.step('route should exist in service routes list', async () => {
+ // Navigate back to service routes list
+ await servicesPom.toServiceRoutes(page, testServiceId);
+ await servicesPom.isServiceRoutesPage(page);
+
+ await expect(page.getByRole('cell', { name: routeName })).toBeVisible();
+
+ // Click on the route name to go to the detail page
+ await page
+ .getByRole('row', { name: routeName })
+ .getByRole('button', { name: 'View' })
+ .click();
+ await servicesPom.isServiceRouteDetailPage(page);
+ });
+
+ await test.step('delete route in detail page', async () => {
+ // We're already on the detail page from the previous step
+
+ // Delete the route
+ await page.getByRole('button', { name: 'Delete' }).click();
+
+ await page
+ .getByRole('dialog', { name: 'Delete Route' })
+ .getByRole('button', { name: 'Delete' })
+ .click();
+
+ // Will redirect to service routes page
+ await servicesPom.isServiceRoutesPage(page);
+ await uiHasToastMsg(page, {
+ hasText: 'Delete Route Successfully',
+ });
+ await expect(page.getByRole('cell', { name: routeName })).toBeHidden();
+ });
+});
diff --git a/e2e/tests/services.routes.list.spec.ts b/e2e/tests/services.routes.list.spec.ts
new file mode 100644
index 000000000..abb6b0679
--- /dev/null
+++ b/e2e/tests/services.routes.list.spec.ts
@@ -0,0 +1,239 @@
+/**
+ * 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 { routesPom } from '@e2e/pom/routes';
+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 { expect } from '@playwright/test';
+
+import { deleteAllRoutes, postRouteReq } from '@/apis/routes';
+import { deleteAllServices, postServiceReq } from '@/apis/services';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const serviceName = randomId('test-service');
+const anotherServiceName = randomId('another-service');
+const routes: APISIXType['Route'][] = [
+ {
+ name: randomId('route1'),
+ uri: '/api/v1/test1',
+ methods: ['GET'],
+ },
+ {
+ name: randomId('route2'),
+ uri: '/api/v1/test2',
+ methods: ['POST'],
+ },
+ {
+ name: randomId('route3'),
+ uri: '/api/v1/test3',
+ methods: ['PUT'],
+ },
+];
+
+// Route that uses upstream directly instead of service_id
+const upstreamRoute: APISIXType['Route'] = {
+ name: randomId('upstream-route'),
+ uri: '/api/v1/upstream-test',
+ methods: ['GET'],
+ upstream: {
+ nodes: [{ host: 'example.com', port: 80, weight: 100 }],
+ },
+};
+
+// Route that belongs to another service
+const anotherServiceRoute: APISIXType['Route'] = {
+ name: randomId('another-service-route'),
+ uri: '/api/v1/another-test',
+ methods: ['GET'],
+};
+
+let testServiceId: string;
+let anotherServiceId: string;
+const createdRoutes: string[] = [];
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+ await deleteAllServices(e2eReq);
+
+ // Create a test service for testing service routes
+ const serviceResponse = await postServiceReq(e2eReq, {
+ name: serviceName,
+ desc: 'Test service for route listing',
+ });
+
+ testServiceId = serviceResponse.data.value.id;
+
+ // Create another service
+ const anotherServiceResponse = await postServiceReq(e2eReq, {
+ name: anotherServiceName,
+ desc: 'Another test service for route isolation testing',
+ });
+
+ anotherServiceId = anotherServiceResponse.data.value.id;
+
+ // Create test routes under the service
+ for (const route of routes) {
+ const routeResponse = await postRouteReq(e2eReq, {
+ ...route,
+ service_id: testServiceId,
+ });
+ createdRoutes.push(routeResponse.data.value.id);
+ }
+
+ // Create a route that uses upstream directly instead of service_id
+ await postRouteReq(e2eReq, upstreamRoute);
+
+ // Create a route under another service
+ await postRouteReq(e2eReq, {
+ ...anotherServiceRoute,
+ service_id: anotherServiceId,
+ });
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+ await deleteAllServices(e2eReq);
+});
+
+test('should only show routes with current service_id', async ({ page }) => {
+ await test.step('should only show routes with current service_id', async () => {
+ await servicesPom.toIndex(page);
+ await servicesPom.isIndexPage(page);
+
+ await page
+ .getByRole('row', { name: serviceName })
+ .getByRole('button', { name: 'View' })
+ .click();
+ await servicesPom.isDetailPage(page);
+
+ await servicesPom.getServiceRoutesTab(page).click();
+ await servicesPom.isServiceRoutesPage(page);
+
+ // Routes from another service should not be visible
+ await expect(
+ page.getByRole('cell', { name: anotherServiceRoute.name })
+ ).toBeHidden();
+ // Upstream route (without service_id) should not be visible
+ await expect(
+ page.getByRole('cell', { name: upstreamRoute.name })
+ ).toBeHidden();
+ // Only routes belonging to current service should be visible
+ for (const route of routes) {
+ await expect(page.getByRole('cell', { name: route.name })).toBeVisible();
+ }
+ });
+
+ await test.step('without service_id routes should still exist in the routes list', async () => {
+ await routesPom.toIndex(page);
+ await routesPom.isIndexPage(page);
+
+ // All routes should be visible in the global routes list
+ await expect(
+ page.getByRole('cell', { name: upstreamRoute.name })
+ ).toBeVisible();
+ await expect(
+ page.getByRole('cell', { name: anotherServiceRoute.name })
+ ).toBeVisible();
+ for (const route of routes) {
+ await expect(page.getByRole('cell', { name: route.name })).toBeVisible();
+ }
+ });
+});
+
+test('should display routes list under service', async ({ page }) => {
+ // Navigate to service detail page
+ await servicesPom.toIndex(page);
+ await servicesPom.isIndexPage(page);
+
+ // Click on the service to go to detail page
+ await page
+ .getByRole('row', { name: serviceName })
+ .getByRole('button', { name: 'View' })
+ .click();
+ await servicesPom.isDetailPage(page);
+
+ // Navigate to Routes tab
+ await servicesPom.getServiceRoutesTab(page).click();
+ await servicesPom.isServiceRoutesPage(page);
+
+ await test.step('should display all routes under service', async () => {
+ // Verify all created routes are displayed
+ for (const route of routes) {
+ await expect(page.getByRole('cell', { name: route.name })).toBeVisible();
+ await expect(page.getByRole('cell', { name: route.uri })).toBeVisible();
+ }
+ });
+
+ await test.step('should have correct table headers', async () => {
+ await expect(page.getByRole('columnheader', { name: 'ID' })).toBeVisible();
+ await expect(
+ page.getByRole('columnheader', { name: 'Name' })
+ ).toBeVisible();
+ await expect(page.getByRole('columnheader', { name: 'URI' })).toBeVisible();
+ await expect(
+ page.getByRole('columnheader', { name: 'Actions' })
+ ).toBeVisible();
+ });
+
+ await test.step('should be able to navigate to route detail', async () => {
+ // Click on the first route's View button
+ await page
+ .getByRole('row', { name: routes[0].name })
+ .getByRole('button', { name: 'View' })
+ .click();
+
+ await servicesPom.isServiceRouteDetailPage(page);
+
+ // Verify we're on the correct route detail page
+ const nameField = page.getByLabel('Name', { exact: true }).first();
+ await expect(nameField).toHaveValue(routes[0].name);
+
+ // Verify service_id is correct
+ const serviceIdField = page.getByLabel('Service ID', { exact: true });
+ await expect(serviceIdField).toHaveValue(testServiceId);
+ });
+
+ await test.step('should have Add Route button', async () => {
+ // Navigate back to service routes list
+ await servicesPom.toServiceRoutes(page, testServiceId);
+ await servicesPom.isServiceRoutesPage(page);
+
+ // Verify Add Route button exists and is clickable
+ const addRouteBtn = servicesPom.getAddRouteBtn(page);
+ await expect(addRouteBtn).toBeVisible();
+
+ await addRouteBtn.click();
+ await servicesPom.isServiceRouteAddPage(page);
+
+ // Verify service_id is pre-filled
+ const serviceIdField = page.getByLabel('Service ID', { exact: true });
+ await expect(serviceIdField).toHaveValue(testServiceId);
+ await expect(serviceIdField).toBeDisabled();
+ });
+
+ await test.step('should show correct route count', async () => {
+ // Navigate back to service routes list
+ await servicesPom.toServiceRoutes(page, testServiceId);
+ await servicesPom.isServiceRoutesPage(page);
+
+ // Check that all 3 routes are displayed in the table
+ const tableRows = page.locator('tbody tr');
+ await expect(tableRows).toHaveCount(routes.length);
+ });
+});
+
diff --git a/e2e/utils/ui/index.ts b/e2e/utils/ui/index.ts
index 580ce2eaf..a16d619bf 100644
--- a/e2e/utils/ui/index.ts
+++ b/e2e/utils/ui/index.ts
@@ -22,8 +22,18 @@ import type { FileRouteTypes } from '@/routeTree.gen';
import { env } from '../env';
-export const uiGoto = (page: Page, path: FileRouteTypes['to']) => {
- return page.goto(`${env.E2E_TARGET_URL}${path.substring(1)}`);
+export const uiGoto = <T extends FileRouteTypes['to']>(
+ page: Page,
+ path: T,
+ params?: T extends `${string}$${string}` ? Record<string, string> : never
+) => {
+ let finalPath = path as string;
+ if (params) {
+ Object.entries(params).forEach(([key, value]) => {
+ finalPath = finalPath.replace(`$${key}`, value);
+ });
+ }
+ return page.goto(`${env.E2E_TARGET_URL}${finalPath.substring(1)}`);
};
export const uiHasToastMsg = async (