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 70712bd33 test: stream_routes in services (#3113) 70712bd33 is described below commit 70712bd33f55f7979d4cb73a898e9778e0fbfe8b Author: YYYoung <isk...@outlook.com> AuthorDate: Mon Jun 16 15:38:00 2025 +0800 test: stream_routes in services (#3113) * feat(services/routes): filter by `service_id` * feat(services): filter stream_routes by service_id * fix: type * test: routes in services * test: stream_routes in services * Revert "test: stream_routes in services" This reverts commit 77d7803250e0365f85dc0599f9d3fbaf8a1b10f5. * test: stream_routes in services * chore: rm stream_routes pom * feat: add only show routes with service_id case * test: add not show other service route case * feat: add services stream_routes pom * test: stream_routes in services * fix: use exact match * chore: rollback useless change --- e2e/pom/services.ts | 41 +++++ e2e/tests/services.stream_routes.crud.spec.ts | 249 +++++++++++++++++++++++++ e2e/tests/services.stream_routes.list.spec.ts | 253 ++++++++++++++++++++++++++ 3 files changed, 543 insertions(+) diff --git a/e2e/pom/services.ts b/e2e/pom/services.ts index 7ac701cf6..60334c78f 100644 --- a/e2e/pom/services.ts +++ b/e2e/pom/services.ts @@ -29,6 +29,11 @@ const locator = { page.getByRole('tab', { name: 'Routes', exact: true }), getAddRouteBtn: (page: Page) => page.getByRole('button', { name: 'Add Route', exact: true }), + // Service stream routes locators + getServiceStreamRoutesTab: (page: Page) => + page.getByRole('tab', { name: 'Stream Routes', exact: true }), + getAddStreamRouteBtn: (page: Page) => + page.getByRole('button', { name: 'Add Stream Route', exact: true }), }; const assert = { @@ -81,6 +86,36 @@ const assert = { const title = page.getByRole('heading', { name: 'Route Detail' }); await expect(title).toBeVisible(); }, + // Service stream routes assertions + isServiceStreamRoutesPage: async (page: Page) => { + await expect(page).toHaveURL( + (url) => + url.pathname.includes('/services/detail') && + url.pathname.includes('/stream_routes') + ); + // Wait for page to load completely + await page.waitForLoadState('networkidle'); + const title = page.getByRole('heading', { name: 'Stream Routes' }); + await expect(title).toBeVisible(); + }, + isServiceStreamRouteAddPage: async (page: Page) => { + await expect(page).toHaveURL( + (url) => + url.pathname.includes('/services/detail') && + url.pathname.includes('/stream_routes/add') + ); + const title = page.getByRole('heading', { name: 'Add Stream Route' }); + await expect(title).toBeVisible(); + }, + isServiceStreamRouteDetailPage: async (page: Page) => { + await expect(page).toHaveURL( + (url) => + url.pathname.includes('/services/detail') && + url.pathname.includes('/stream_routes/detail') + ); + const title = page.getByRole('heading', { name: 'Stream Route Detail' }); + await expect(title).toBeVisible(); + }, }; const goto = { @@ -92,6 +127,12 @@ const goto = { uiGoto(page, '/services/detail/$id/routes/add', { id: serviceId, }), + toServiceStreamRoutes: (page: Page, serviceId: string) => + uiGoto(page, '/services/detail/$id/stream_routes', { id: serviceId }), + toServiceStreamRouteAdd: (page: Page, serviceId: string) => + uiGoto(page, '/services/detail/$id/stream_routes/add', { + id: serviceId, + }), }; export const servicesPom = { diff --git a/e2e/tests/services.stream_routes.crud.spec.ts b/e2e/tests/services.stream_routes.crud.spec.ts new file mode 100644 index 000000000..a53f641b3 --- /dev/null +++ b/e2e/tests/services.stream_routes.crud.spec.ts @@ -0,0 +1,249 @@ +/** + * 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 { expect } from '@playwright/test'; + +import { deleteAllServices, postServiceReq } from '@/apis/services'; +import { deleteAllStreamRoutes } from '@/apis/stream_routes'; + +const serviceName = randomId('test-service'); +const streamRouteServerAddr = '127.0.0.1'; +const streamRouteServerPort = 8080; +const updatedStreamRouteServerAddr = '127.0.0.2'; +const updatedStreamRouteServerPort = 8081; + +let testServiceId: string; + +test.beforeAll(async () => { + await deleteAllStreamRoutes(e2eReq); + await deleteAllServices(e2eReq); + + // Create a test service for testing service stream routes + const serviceResponse = await postServiceReq(e2eReq, { + name: serviceName, + desc: 'Test service for stream route CRUD testing', + }); + + testServiceId = serviceResponse.data.value.id; +}); + +test.afterAll(async () => { + await deleteAllStreamRoutes(e2eReq); + await deleteAllServices(e2eReq); +}); + +test('should CRUD stream route 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 Stream Routes tab + await servicesPom.getServiceStreamRoutesTab(page).click(); + await servicesPom.isServiceStreamRoutesPage(page); + + await servicesPom.getAddStreamRouteBtn(page).click(); + await servicesPom.isServiceStreamRouteAddPage(page); + + await test.step('can submit without any fields (no required fields)', async () => { + // 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(); + + // Submit the form without filling any other fields + await servicesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { + hasText: 'Add Stream Route Successfully', + }); + }); + + await test.step('auto navigate to stream route detail page', async () => { + await servicesPom.isServiceStreamRouteDetailPage(page); + + // Verify the stream route details + // Verify ID exists + const ID = page.getByRole('textbox', { name: 'ID', exact: true }); + await expect(ID).toBeVisible(); + await expect(ID).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(); + + // Verify default values for server address and port (should be empty initially) + const serverAddrField = page.getByLabel('Server Address', { exact: true }); + const serverPortField = page.getByLabel('Server Port', { exact: true }); + + // These fields might be empty or have default values + await expect(serverAddrField).toBeVisible(); + await expect(serverPortField).toBeVisible(); + }); + + await test.step('edit and update stream route with some fields', 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 serverAddrField = page.getByLabel('Server Address', { exact: true }); + await expect(serverAddrField).toBeEnabled(); + + // Service ID should still be disabled even in edit mode + const serviceIdField = page.getByLabel('Service ID', { exact: true }); + await expect(serviceIdField).toBeDisabled(); + + // Fill in some fields + await serverAddrField.fill(streamRouteServerAddr); + + const serverPortField = page.getByLabel('Server Port', { exact: true }); + await serverPortField.fill(streamRouteServerPort.toString()); + + // 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.isServiceStreamRouteDetailPage(page); + + // Verify the updated fields + await expect( + page.getByLabel('Server Address', { exact: true }) + ).toHaveValue(streamRouteServerAddr); + await expect(page.getByLabel('Server Port', { exact: true })).toHaveValue( + streamRouteServerPort.toString() + ); + }); + + await test.step('edit again and update with different values', async () => { + // Click the Edit button again + await page.getByRole('button', { name: 'Edit' }).click(); + + // Update with different values + const serverAddrField = page.getByLabel('Server Address', { exact: true }); + await serverAddrField.fill(updatedStreamRouteServerAddr); + + const serverPortField = page.getByLabel('Server Port', { exact: true }); + await serverPortField.fill(updatedStreamRouteServerPort.toString()); + + // Click the Save button + const saveBtn = page.getByRole('button', { name: 'Save' }); + await saveBtn.click(); + + // Verify the update was successful + await uiHasToastMsg(page, { + hasText: 'success', + }); + + // Verify the updated values + await expect( + page.getByLabel('Server Address', { exact: true }) + ).toHaveValue(updatedStreamRouteServerAddr); + await expect(page.getByLabel('Server Port', { exact: true })).toHaveValue( + updatedStreamRouteServerPort.toString() + ); + }); + + await test.step('stream route should exist in service stream routes list', async () => { + // Navigate back to service stream routes list + await servicesPom.toServiceStreamRoutes(page, testServiceId); + await servicesPom.isServiceStreamRoutesPage(page); + + // Verify the stream route appears in the list with updated values + await expect( + page.getByRole('cell', { name: updatedStreamRouteServerAddr }) + ).toBeVisible(); + await expect( + page.getByRole('cell', { name: updatedStreamRouteServerPort.toString() }) + ).toBeVisible(); + + // Click on the stream route to go to the detail page + await page + .getByRole('row', { name: updatedStreamRouteServerAddr }) + .getByRole('button', { name: 'View' }) + .click(); + await servicesPom.isServiceStreamRouteDetailPage(page); + }); + + await test.step('delete stream route in detail page', async () => { + // We're already on the detail page from the previous step + + // Delete the stream route + await page.getByRole('button', { name: 'Delete' }).click(); + + await page + .getByRole('dialog', { name: 'Delete Stream Route' }) + .getByRole('button', { name: 'Delete' }) + .click(); + + // Will redirect to service stream routes page + await servicesPom.isServiceStreamRoutesPage(page); + await uiHasToastMsg(page, { + hasText: 'Delete Stream Route Successfully', + }); + + // Verify the stream route is no longer in the list + await expect( + page.getByRole('cell', { name: updatedStreamRouteServerAddr }) + ).toBeHidden(); + }); + + await test.step('create another stream route with minimal fields', async () => { + // Add another stream route to test creation with minimal data + await servicesPom.getAddStreamRouteBtn(page).click(); + await servicesPom.isServiceStreamRouteAddPage(page); + + // Just fill server address this time + const serverAddrField = page.getByLabel('Server Address', { exact: true }); + await serverAddrField.fill('192.168.1.1'); + + // Submit the form + await servicesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { + hasText: 'Add Stream Route Successfully', + }); + + // Verify we're on the detail page + await servicesPom.isServiceStreamRouteDetailPage(page); + await expect( + page.getByLabel('Server Address', { exact: true }) + ).toHaveValue('192.168.1.1'); + + // Clean up - delete this stream route too + await page.getByRole('button', { name: 'Delete' }).click(); + await page + .getByRole('dialog', { name: 'Delete Stream Route' }) + .getByRole('button', { name: 'Delete' }) + .click(); + await servicesPom.isServiceStreamRoutesPage(page); + }); +}); diff --git a/e2e/tests/services.stream_routes.list.spec.ts b/e2e/tests/services.stream_routes.list.spec.ts new file mode 100644 index 000000000..07044fbed --- /dev/null +++ b/e2e/tests/services.stream_routes.list.spec.ts @@ -0,0 +1,253 @@ +/** + * 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 { uiGoto } from '@e2e/utils/ui'; +import { expect } from '@playwright/test'; + +import { deleteAllServices, postServiceReq } from '@/apis/services'; +import { + deleteAllStreamRoutes, + postStreamRouteReq, +} from '@/apis/stream_routes'; + +const serviceName = randomId('test-service'); +const anotherServiceName = randomId('another-service'); +const streamRoutes = [ + { + server_addr: '127.0.0.1', + server_port: 8080, + }, + { + server_addr: '127.0.0.2', + server_port: 8081, + }, + { + server_addr: '127.0.0.3', + server_port: 8082, + }, +]; + +// Stream route that uses upstream directly instead of service_id +const upstreamStreamRoute = { + server_addr: '127.0.0.40', + server_port: 9090, + upstream: { + nodes: [{ host: 'example.com', port: 80, weight: 100 }], + }, +}; + +// Stream route that belongs to another service +const anotherServiceStreamRoute = { + server_addr: '127.0.0.20', + server_port: 9091, +}; + +let testServiceId: string; +let anotherServiceId: string; +const createdStreamRoutes: string[] = []; + +test.beforeAll(async () => { + await deleteAllStreamRoutes(e2eReq); + await deleteAllServices(e2eReq); + + // Create a test service for testing service stream routes + const serviceResponse = await postServiceReq(e2eReq, { + name: serviceName, + desc: 'Test service for stream route listing', + }); + + testServiceId = serviceResponse.data.value.id; + + // Create another service + const anotherServiceResponse = await postServiceReq(e2eReq, { + name: anotherServiceName, + desc: 'Another test service for stream route isolation testing', + }); + + anotherServiceId = anotherServiceResponse.data.value.id; + + // Create test stream routes under the service + for (const streamRoute of streamRoutes) { + const streamRouteResponse = await postStreamRouteReq(e2eReq, { + server_addr: streamRoute.server_addr, + server_port: streamRoute.server_port, + service_id: testServiceId, + }); + createdStreamRoutes.push(streamRouteResponse.data.value.id); + } + + // Create a stream route that uses upstream directly instead of service_id + await postStreamRouteReq(e2eReq, upstreamStreamRoute); + + // Create a stream route under another service + await postStreamRouteReq(e2eReq, { + ...anotherServiceStreamRoute, + service_id: anotherServiceId, + }); +}); + +test.afterAll(async () => { + await deleteAllStreamRoutes(e2eReq); + await deleteAllServices(e2eReq); +}); + +test('should only show stream routes with current service_id', async ({ + page, +}) => { + await test.step('should only show stream 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.getServiceStreamRoutesTab(page).click(); + await servicesPom.isServiceStreamRoutesPage(page); + + // Stream routes from another service should not be visible + await expect( + page.getByRole('cell', { name: anotherServiceStreamRoute.server_addr }) + ).toBeHidden(); + // Upstream stream route (without service_id) should not be visible + await expect( + page.getByRole('cell', { name: upstreamStreamRoute.server_addr }) + ).toBeHidden(); + // Only stream routes belonging to current service should be visible + for (const streamRoute of streamRoutes) { + await expect( + page.getByRole('cell', { name: streamRoute.server_addr }) + ).toBeVisible(); + } + }); + + await test.step('without service_id stream routes should still exist in the stream routes list', async () => { + await uiGoto(page, '/stream_routes'); + await expect(page).toHaveURL((url) => + url.pathname.endsWith('/stream_routes') + ); + const title = page.getByRole('heading', { name: 'Stream Routes' }); + await expect(title).toBeVisible(); + + // All stream routes should be visible in the global stream routes list + await expect( + page.getByRole('cell', { name: upstreamStreamRoute.server_addr }) + ).toBeVisible(); + await expect( + page.getByRole('cell', { name: anotherServiceStreamRoute.server_addr }) + ).toBeVisible(); + for (const streamRoute of streamRoutes) { + await expect( + page.getByRole('cell', { name: streamRoute.server_addr, exact: true }) + ).toBeVisible(); + } + }); +}); + +test('should display stream 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 Stream Routes tab + await servicesPom.getServiceStreamRoutesTab(page).click(); + await servicesPom.isServiceStreamRoutesPage(page); + + await test.step('should display all stream routes under service', async () => { + // Verify all created stream routes are displayed + for (const streamRoute of streamRoutes) { + await expect( + page.getByRole('cell', { name: streamRoute.server_addr }) + ).toBeVisible(); + await expect( + page.getByRole('cell', { name: streamRoute.server_port.toString() }) + ).toBeVisible(); + } + }); + + await test.step('should have correct table headers', async () => { + await expect(page.getByRole('columnheader', { name: 'ID' })).toBeVisible(); + await expect( + page.getByRole('columnheader', { name: 'Server Address' }) + ).toBeVisible(); + await expect( + page.getByRole('columnheader', { name: 'Server Port' }) + ).toBeVisible(); + await expect( + page.getByRole('columnheader', { name: 'Actions' }) + ).toBeVisible(); + }); + + await test.step('should be able to navigate to stream route detail', async () => { + // Click on the first stream route's View button + await page + .getByRole('row', { name: streamRoutes[0].server_addr }) + .getByRole('button', { name: 'View' }) + .click(); + + await servicesPom.isServiceStreamRouteDetailPage(page); + + // Verify we're on the correct stream route detail page + const serverAddrField = page.getByLabel('Server Address', { exact: true }); + await expect(serverAddrField).toHaveValue(streamRoutes[0].server_addr); + + // Verify service_id is correct + const serviceIdField = page.getByLabel('Service ID', { exact: true }); + await expect(serviceIdField).toHaveValue(testServiceId); + }); + + await test.step('should have Add Stream Route button', async () => { + // Navigate back to service stream routes list + await servicesPom.toServiceStreamRoutes(page, testServiceId); + await servicesPom.isServiceStreamRoutesPage(page); + + // Verify Add Stream Route button exists and is clickable + const addStreamRouteBtn = servicesPom.getAddStreamRouteBtn(page); + await expect(addStreamRouteBtn).toBeVisible(); + + await addStreamRouteBtn.click(); + await servicesPom.isServiceStreamRouteAddPage(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 stream route count', async () => { + // Navigate back to service stream routes list + await servicesPom.toServiceStreamRoutes(page, testServiceId); + await servicesPom.isServiceStreamRoutesPage(page); + + // Check that all 3 stream routes are displayed in the table + const tableRows = page.locator('tbody tr'); + await expect(tableRows).toHaveCount(streamRoutes.length); + }); +}); +