From 67aa1e43c1e80bc77f9d06fb8b267bfbf9ff8337 Mon Sep 17 00:00:00 2001 From: Skye Young Date: Tue, 10 Jun 2025 15:38:55 +0800 Subject: [PATCH 01/14] feat(services/routes): filter by `service_id` --- src/apis/hooks.ts | 8 +++++--- src/apis/routes.ts | 10 +++++++++- src/config/req.ts | 10 ++++++++-- src/routes/routes/index.tsx | 9 +++++++-- src/routes/services/detail.$id/routes/index.tsx | 5 +++++ 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/apis/hooks.ts b/src/apis/hooks.ts index 1e427c45ea..4825f3398d 100644 --- a/src/apis/hooks.ts +++ b/src/apis/hooks.ts @@ -82,14 +82,16 @@ export const genUseList = < routeKey: T, listQueryOptions: ReturnType> ) => { - return (replaceKey?: U) => { + return (replaceKey?: U, defaultParams?: Partial

) => { const key = replaceKey || routeKey; const { params, setParams } = useSearchParams(key); - const listQuery = useSuspenseQuery(listQueryOptions(params)); + const listQuery = useSuspenseQuery( + listQueryOptions({ ...defaultParams, ...params }) + ); const { data, isLoading, refetch } = listQuery; const opts = { data, setParams, params }; const pagination = useTablePagination(opts); - return { data, isLoading, refetch, pagination }; + return { data, isLoading, refetch, pagination, setParams }; }; }; diff --git a/src/apis/routes.ts b/src/apis/routes.ts index fc5af37173..a17e806f21 100644 --- a/src/apis/routes.ts +++ b/src/apis/routes.ts @@ -21,7 +21,15 @@ import { API_ROUTES, PAGE_SIZE_MAX, PAGE_SIZE_MIN } from '@/config/constant'; import type { APISIXType } from '@/types/schema/apisix'; import type { PageSearchType } from '@/types/schema/pageSearch'; -export const getRouteListReq = (req: AxiosInstance, params: PageSearchType) => +export type GetRouteListReqParams = PageSearchType & { + filter?: { + service_id?: string; + }; +}; +export const getRouteListReq = ( + req: AxiosInstance, + params: GetRouteListReqParams +) => req .get(API_ROUTES, { params }) .then((v) => v.data); diff --git a/src/config/req.ts b/src/config/req.ts index 232a9b00b5..283338febd 100644 --- a/src/config/req.ts +++ b/src/config/req.ts @@ -29,10 +29,16 @@ import { globalStore } from '@/stores/global'; export const req = axios.create(); req.interceptors.request.use((conf) => { - conf.paramsSerializer = (p) => - stringify(p, { + conf.paramsSerializer = (p) => { + // from { filter: { service_id: 1 } } + // to `filter=service_id%3D1` + if (p.filter) { + p.filter = stringify(p.filter); + } + return stringify(p, { arrayFormat: 'repeat', }); + }; conf.baseURL = API_PREFIX; conf.headers.set(API_HEADER_KEY, globalStore.settings.adminKey); return conf; diff --git a/src/routes/routes/index.tsx b/src/routes/routes/index.tsx index 7374b48113..75eaf0eeaf 100644 --- a/src/routes/routes/index.tsx +++ b/src/routes/routes/index.tsx @@ -21,6 +21,7 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { getRouteListQueryOptions, useRouteList } from '@/apis/hooks'; +import type { GetRouteListReqParams } from '@/apis/routes'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; @@ -33,14 +34,18 @@ import type { ListPageKeys } from '@/utils/useTablePagination'; export type RouteListProps = { routeKey: Extract; + defaultParams?: Partial; ToDetailBtn: (props: { record: APISIXType['RespRouteItem']; }) => React.ReactNode; }; export const RouteList = (props: RouteListProps) => { - const { routeKey, ToDetailBtn } = props; - const { data, isLoading, refetch, pagination } = useRouteList(routeKey); + const { routeKey, ToDetailBtn, defaultParams } = props; + const { data, isLoading, refetch, pagination } = useRouteList( + routeKey, + defaultParams + ); const { t } = useTranslation(); const columns = useMemo[]>(() => { diff --git a/src/routes/services/detail.$id/routes/index.tsx b/src/routes/services/detail.$id/routes/index.tsx index 4a041708ae..97e092c629 100644 --- a/src/routes/services/detail.$id/routes/index.tsx +++ b/src/routes/services/detail.$id/routes/index.tsx @@ -32,6 +32,11 @@ function RouteComponent() { ( Date: Tue, 10 Jun 2025 15:52:02 +0800 Subject: [PATCH 02/14] feat(services): filter stream_routes by service_id --- src/apis/routes.ts | 8 +++----- src/apis/stream_routes.ts | 8 ++++++-- src/routes/services/detail.$id/stream_routes/index.tsx | 5 +++++ src/routes/stream_routes/index.tsx | 9 +++++++-- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/apis/routes.ts b/src/apis/routes.ts index a17e806f21..cb99f71776 100644 --- a/src/apis/routes.ts +++ b/src/apis/routes.ts @@ -21,15 +21,13 @@ import { API_ROUTES, PAGE_SIZE_MAX, PAGE_SIZE_MIN } from '@/config/constant'; import type { APISIXType } from '@/types/schema/apisix'; import type { PageSearchType } from '@/types/schema/pageSearch'; -export type GetRouteListReqParams = PageSearchType & { +export type WithServiceIdFilter = PageSearchType & { filter?: { service_id?: string; }; }; -export const getRouteListReq = ( - req: AxiosInstance, - params: GetRouteListReqParams -) => + +export const getRouteListReq = (req: AxiosInstance, params: WithServiceIdFilter) => req .get(API_ROUTES, { params }) .then((v) => v.data); diff --git a/src/apis/stream_routes.ts b/src/apis/stream_routes.ts index 0e26ae2251..0dbf09d308 100644 --- a/src/apis/stream_routes.ts +++ b/src/apis/stream_routes.ts @@ -20,9 +20,13 @@ import type { AxiosInstance } from 'axios'; import type { StreamRoutePostType } from '@/components/form-slice/FormPartStreamRoute/schema'; import { API_STREAM_ROUTES } from '@/config/constant'; import type { APISIXType } from '@/types/schema/apisix'; -import type { PageSearchType } from '@/types/schema/pageSearch'; -export const getStreamRouteListReq = (req: AxiosInstance, params: PageSearchType) => +import type { WithServiceIdFilter } from './routes'; + +export const getStreamRouteListReq = ( + req: AxiosInstance, + params: WithServiceIdFilter +) => req .get(API_STREAM_ROUTES, { params, diff --git a/src/routes/services/detail.$id/stream_routes/index.tsx b/src/routes/services/detail.$id/stream_routes/index.tsx index 8c3f6d1280..f3503272fc 100644 --- a/src/routes/services/detail.$id/stream_routes/index.tsx +++ b/src/routes/services/detail.$id/stream_routes/index.tsx @@ -39,6 +39,11 @@ function StreamRouteComponent() { params={{ id, routeId: record.value.id }} /> )} + defaultParams={{ + filter: { + service_id: id, + }, + }} /> ); diff --git a/src/routes/stream_routes/index.tsx b/src/routes/stream_routes/index.tsx index 1909744c83..3fb5247ba3 100644 --- a/src/routes/stream_routes/index.tsx +++ b/src/routes/stream_routes/index.tsx @@ -21,6 +21,7 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { getStreamRouteListQueryOptions, useStreamRouteList } from '@/apis/hooks'; +import type { WithServiceIdFilter } from '@/apis/routes'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; @@ -39,11 +40,15 @@ export type StreamRouteListProps = { ToDetailBtn: (props: { record: APISIXType['RespStreamRouteItem']; }) => React.ReactNode; + defaultParams?: Partial; }; export const StreamRouteList = (props: StreamRouteListProps) => { - const { routeKey, ToDetailBtn } = props; - const { data, isLoading, refetch, pagination } = useStreamRouteList(routeKey); + const { routeKey, ToDetailBtn, defaultParams } = props; + const { data, isLoading, refetch, pagination } = useStreamRouteList( + routeKey, + defaultParams + ); const { t } = useTranslation(); const columns = useMemo< From 724ea86c7abc1ab635d8012e8fa67ca47221ab62 Mon Sep 17 00:00:00 2001 From: Skye Young Date: Tue, 10 Jun 2025 16:17:08 +0800 Subject: [PATCH 03/14] fix: type --- src/routes/routes/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/routes/index.tsx b/src/routes/routes/index.tsx index 75eaf0eeaf..6b44fbd776 100644 --- a/src/routes/routes/index.tsx +++ b/src/routes/routes/index.tsx @@ -21,7 +21,7 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { getRouteListQueryOptions, useRouteList } from '@/apis/hooks'; -import type { GetRouteListReqParams } from '@/apis/routes'; +import type { WithServiceIdFilter } from '@/apis/routes'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; @@ -34,7 +34,7 @@ import type { ListPageKeys } from '@/utils/useTablePagination'; export type RouteListProps = { routeKey: Extract; - defaultParams?: Partial; + defaultParams?: Partial; ToDetailBtn: (props: { record: APISIXType['RespRouteItem']; }) => React.ReactNode; From 3ef9e472053fe661fe90a50461f132a42b0aa37e Mon Sep 17 00:00:00 2001 From: Skye Young Date: Tue, 10 Jun 2025 17:19:09 +0800 Subject: [PATCH 04/14] test: routes in services --- e2e/pom/services.ts | 82 +++++++++ e2e/tests/services.routes.crud.spec.ts | 220 +++++++++++++++++++++++++ e2e/tests/services.routes.list.spec.ts | 162 ++++++++++++++++++ e2e/utils/ui/index.ts | 14 +- 4 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 e2e/tests/services.routes.crud.spec.ts create mode 100644 e2e/tests/services.routes.list.spec.ts diff --git a/e2e/pom/services.ts b/e2e/pom/services.ts index b3b4685d19..60334c78f4 100644 --- a/e2e/pom/services.ts +++ b/e2e/pom/services.ts @@ -24,6 +24,16 @@ 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 }), + // 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 = { @@ -46,11 +56,83 @@ 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(); + }, + // 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 = { 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, + }), + 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.routes.crud.spec.ts b/e2e/tests/services.routes.crud.spec.ts new file mode 100644 index 0000000000..16e6480dbf --- /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 0000000000..51c451ff84 --- /dev/null +++ b/e2e/tests/services.routes.list.spec.ts @@ -0,0 +1,162 @@ +/** + * 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 type { HttpMethod } from '@e2e/type'; +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'; + +const serviceName = randomId('test-service'); +const routes = [ + { + 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'], + }, +] as Array<{ + name: string; + uri: string; + methods: HttpMethod[]; +}>; + +let testServiceId: 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 test routes under the service + for (const route of routes) { + const routeResponse = await postRouteReq(e2eReq, { + name: route.name, + uri: route.uri, + methods: route.methods, + service_id: testServiceId, + }); + createdRoutes.push(routeResponse.data.value.id); + } +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); + await deleteAllServices(e2eReq); +}); + +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 580ce2eaf2..a16d619bf7 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 = ( + page: Page, + path: T, + params?: T extends `${string}$${string}` ? Record : 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 ( From 77d7803250e0365f85dc0599f9d3fbaf8a1b10f5 Mon Sep 17 00:00:00 2001 From: Skye Young Date: Tue, 10 Jun 2025 17:21:55 +0800 Subject: [PATCH 05/14] test: stream_routes in services --- e2e/tests/services.stream_routes.crud.spec.ts | 249 ++++++++++++++++++ e2e/tests/services.stream_routes.list.spec.ts | 162 ++++++++++++ src/apis/stream_routes.ts | 24 +- .../form-slice/FormPartStreamRoute/index.tsx | 2 + src/components/form/JsonInput.tsx | 13 +- src/routes/stream_routes/add.tsx | 6 +- src/routes/stream_routes/detail.$id.tsx | 6 +- src/routes/stream_routes/index.tsx | 12 +- src/types/schema/apisix/stream_routes.ts | 4 +- 9 files changed, 465 insertions(+), 13 deletions(-) create mode 100644 e2e/tests/services.stream_routes.crud.spec.ts create mode 100644 e2e/tests/services.stream_routes.list.spec.ts 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 0000000000..a53f641b38 --- /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 0000000000..e74ebfd2e6 --- /dev/null +++ b/e2e/tests/services.stream_routes.list.spec.ts @@ -0,0 +1,162 @@ +/** + * 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 { expect } from '@playwright/test'; + +import { deleteAllServices, postServiceReq } from '@/apis/services'; +import { + deleteAllStreamRoutes, + postStreamRouteReq, +} from '@/apis/stream_routes'; + +const serviceName = randomId('test-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, + }, +]; + +let testServiceId: 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 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); + } +}); + +test.afterAll(async () => { + await deleteAllStreamRoutes(e2eReq); + await deleteAllServices(e2eReq); +}); + +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); + }); +}); diff --git a/src/apis/stream_routes.ts b/src/apis/stream_routes.ts index 0dbf09d308..c01f58db59 100644 --- a/src/apis/stream_routes.ts +++ b/src/apis/stream_routes.ts @@ -18,7 +18,11 @@ import type { AxiosInstance } from 'axios'; import type { StreamRoutePostType } from '@/components/form-slice/FormPartStreamRoute/schema'; -import { API_STREAM_ROUTES } from '@/config/constant'; +import { + API_STREAM_ROUTES, + PAGE_SIZE_MAX, + PAGE_SIZE_MIN, +} from '@/config/constant'; import type { APISIXType } from '@/types/schema/apisix'; import type { WithServiceIdFilter } from './routes'; @@ -59,3 +63,21 @@ export const postStreamRouteReq = ( API_STREAM_ROUTES, data ); + +export const deleteAllStreamRoutes = async (req: AxiosInstance) => { + const totalRes = await getStreamRouteListReq(req, { + page: 1, + page_size: PAGE_SIZE_MIN, + }); + const total = totalRes.total; + if (total === 0) return; + for (let times = Math.ceil(total / PAGE_SIZE_MAX); times > 0; times--) { + const res = await getStreamRouteListReq(req, { + page: 1, + page_size: PAGE_SIZE_MAX, + }); + await Promise.all( + res.list.map((d) => req.delete(`${API_STREAM_ROUTES}/${d.value.id}`)) + ); + } +}; diff --git a/src/components/form-slice/FormPartStreamRoute/index.tsx b/src/components/form-slice/FormPartStreamRoute/index.tsx index 8b6bf11410..5c0bdf5ae0 100644 --- a/src/components/form-slice/FormPartStreamRoute/index.tsx +++ b/src/components/form-slice/FormPartStreamRoute/index.tsx @@ -81,12 +81,14 @@ const FormSectionStreamRouteProtocol = () => { control={control} name="protocol.conf" label={t('form.streamRoutes.protocol.conf')} + toObject /> ); diff --git a/src/components/form/JsonInput.tsx b/src/components/form/JsonInput.tsx index 3796c11395..302de1ed25 100644 --- a/src/components/form/JsonInput.tsx +++ b/src/components/form/JsonInput.tsx @@ -15,6 +15,7 @@ * limitations under the License. */ import { JsonInput, type JsonInputProps } from '@mantine/core'; +import { omit } from 'rambdax'; import { useMemo } from 'react'; import { type FieldValues, @@ -28,15 +29,17 @@ export type FormItemJsonInputProps = UseControllerProps & JsonInputProps & { toObject?: boolean; + objValue?: unknown; }; export const FormItemJsonInput = ( props: FormItemJsonInputProps ) => { + const { objValue = {} } = props; const { controllerProps, restProps: { toObject, ...restProps }, - } = genControllerProps(props, props.toObject ? {} : ''); + } = genControllerProps(props, props.toObject ? objValue : ''); const { field: { value: rawVal, onChange: fOnChange, ...restField }, fieldState, @@ -45,9 +48,9 @@ export const FormItemJsonInput = ( if (!toObject) return rawVal; if (typeof rawVal === 'string') return rawVal; const val = JSON.stringify(rawVal, null, 2); - if (val === '{}') return ''; + if (val === JSON.stringify(objValue)) return ''; return val; - }, [rawVal, toObject]); + }, [rawVal, toObject, objValue]); return ( ( try { res = JSON.parse(val); } catch { - res = val.length === 0 ? {} : val; + res = val.length === 0 ? objValue : val; } } fOnChange(res); @@ -69,7 +72,7 @@ export const FormItemJsonInput = ( autosize resize="vertical" {...restField} - {...restProps} + {...omit(['objValue'], restProps)} /> ); }; diff --git a/src/routes/stream_routes/add.tsx b/src/routes/stream_routes/add.tsx index bec082e8e5..9131aa76c6 100644 --- a/src/routes/stream_routes/add.tsx +++ b/src/routes/stream_routes/add.tsx @@ -32,6 +32,7 @@ import { FormTOCBox } from '@/components/form-slice/FormSection'; import PageHeader from '@/components/page/PageHeader'; import { req } from '@/config/req'; import type { APISIXType } from '@/types/schema/apisix'; +import { produceRmUpstreamWhenHas } from '@/utils/form-producer'; import { pipeProduce } from '@/utils/producer'; type Props = { @@ -45,7 +46,10 @@ export const StreamRouteAddForm = (props: Props) => { const postStreamRoute = useMutation({ mutationFn: (d: StreamRoutePostType) => - postStreamRouteReq(req, pipeProduce()(d)), + postStreamRouteReq( + req, + pipeProduce(produceRmUpstreamWhenHas('service_id'))(d) + ), async onSuccess(res) { notifications.show({ message: t('info.add.success', { name: t('streamRoutes.singular') }), diff --git a/src/routes/stream_routes/detail.$id.tsx b/src/routes/stream_routes/detail.$id.tsx index 4232e95d03..e9f641a7f4 100644 --- a/src/routes/stream_routes/detail.$id.tsx +++ b/src/routes/stream_routes/detail.$id.tsx @@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader'; import { API_STREAM_ROUTES } from '@/config/constant'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; +import { produceRmUpstreamWhenHas } from '@/utils/form-producer'; import { pipeProduce } from '@/utils/producer'; type Props = { @@ -70,7 +71,10 @@ const StreamRouteDetailForm = (props: Props) => { const putStreamRoute = useMutation({ mutationFn: (d: APISIXType['StreamRoute']) => - putStreamRouteReq(req, pipeProduce()(d)), + putStreamRouteReq( + req, + pipeProduce(produceRmUpstreamWhenHas('service_id'))(d) + ), async onSuccess() { notifications.show({ message: t('info.edit.success', { name: t('streamRoutes.singular') }), diff --git a/src/routes/stream_routes/index.tsx b/src/routes/stream_routes/index.tsx index 3fb5247ba3..0c7cf29d89 100644 --- a/src/routes/stream_routes/index.tsx +++ b/src/routes/stream_routes/index.tsx @@ -62,9 +62,15 @@ export const StreamRouteList = (props: StreamRouteListProps) => { valueType: 'text', }, { - dataIndex: ['value', 'name'], - title: t('form.basic.name'), - key: 'name', + dataIndex: ['value', 'server_addr'], + title: t('form.streamRoutes.serverAddr'), + key: 'server_addr', + valueType: 'text', + }, + { + dataIndex: ['value', 'server_port'], + title: t('form.streamRoutes.serverPort'), + key: 'server_port', valueType: 'text', }, { diff --git a/src/types/schema/apisix/stream_routes.ts b/src/types/schema/apisix/stream_routes.ts index fcea623b5a..77757abe0d 100644 --- a/src/types/schema/apisix/stream_routes.ts +++ b/src/types/schema/apisix/stream_routes.ts @@ -28,8 +28,8 @@ const StreamRouteProtocolLoggerItem = z.object({ const StreamRouteProtocol = z.object({ name: z.string(), superior_id: z.string(), - conf: z.object({}), - logger: z.array(StreamRouteProtocolLoggerItem), + conf: z.object({}).optional(), + logger: z.array(StreamRouteProtocolLoggerItem).optional(), }); const StreamRoute = z From ffc1fd6148575ddf75db306d285937b79d6432e6 Mon Sep 17 00:00:00 2001 From: Skye Young Date: Tue, 10 Jun 2025 17:27:28 +0800 Subject: [PATCH 06/14] Revert "test: stream_routes in services" This reverts commit 77d7803250e0365f85dc0599f9d3fbaf8a1b10f5. --- e2e/tests/services.stream_routes.crud.spec.ts | 249 ------------------ e2e/tests/services.stream_routes.list.spec.ts | 162 ------------ src/apis/stream_routes.ts | 24 +- .../form-slice/FormPartStreamRoute/index.tsx | 2 - src/components/form/JsonInput.tsx | 13 +- src/routes/stream_routes/add.tsx | 6 +- src/routes/stream_routes/detail.$id.tsx | 6 +- src/routes/stream_routes/index.tsx | 12 +- src/types/schema/apisix/stream_routes.ts | 4 +- 9 files changed, 13 insertions(+), 465 deletions(-) delete mode 100644 e2e/tests/services.stream_routes.crud.spec.ts delete mode 100644 e2e/tests/services.stream_routes.list.spec.ts diff --git a/e2e/tests/services.stream_routes.crud.spec.ts b/e2e/tests/services.stream_routes.crud.spec.ts deleted file mode 100644 index a53f641b38..0000000000 --- a/e2e/tests/services.stream_routes.crud.spec.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * 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 deleted file mode 100644 index e74ebfd2e6..0000000000 --- a/e2e/tests/services.stream_routes.list.spec.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * 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 { expect } from '@playwright/test'; - -import { deleteAllServices, postServiceReq } from '@/apis/services'; -import { - deleteAllStreamRoutes, - postStreamRouteReq, -} from '@/apis/stream_routes'; - -const serviceName = randomId('test-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, - }, -]; - -let testServiceId: 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 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); - } -}); - -test.afterAll(async () => { - await deleteAllStreamRoutes(e2eReq); - await deleteAllServices(e2eReq); -}); - -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); - }); -}); diff --git a/src/apis/stream_routes.ts b/src/apis/stream_routes.ts index c01f58db59..0dbf09d308 100644 --- a/src/apis/stream_routes.ts +++ b/src/apis/stream_routes.ts @@ -18,11 +18,7 @@ import type { AxiosInstance } from 'axios'; import type { StreamRoutePostType } from '@/components/form-slice/FormPartStreamRoute/schema'; -import { - API_STREAM_ROUTES, - PAGE_SIZE_MAX, - PAGE_SIZE_MIN, -} from '@/config/constant'; +import { API_STREAM_ROUTES } from '@/config/constant'; import type { APISIXType } from '@/types/schema/apisix'; import type { WithServiceIdFilter } from './routes'; @@ -63,21 +59,3 @@ export const postStreamRouteReq = ( API_STREAM_ROUTES, data ); - -export const deleteAllStreamRoutes = async (req: AxiosInstance) => { - const totalRes = await getStreamRouteListReq(req, { - page: 1, - page_size: PAGE_SIZE_MIN, - }); - const total = totalRes.total; - if (total === 0) return; - for (let times = Math.ceil(total / PAGE_SIZE_MAX); times > 0; times--) { - const res = await getStreamRouteListReq(req, { - page: 1, - page_size: PAGE_SIZE_MAX, - }); - await Promise.all( - res.list.map((d) => req.delete(`${API_STREAM_ROUTES}/${d.value.id}`)) - ); - } -}; diff --git a/src/components/form-slice/FormPartStreamRoute/index.tsx b/src/components/form-slice/FormPartStreamRoute/index.tsx index 5c0bdf5ae0..8b6bf11410 100644 --- a/src/components/form-slice/FormPartStreamRoute/index.tsx +++ b/src/components/form-slice/FormPartStreamRoute/index.tsx @@ -81,14 +81,12 @@ const FormSectionStreamRouteProtocol = () => { control={control} name="protocol.conf" label={t('form.streamRoutes.protocol.conf')} - toObject /> ); diff --git a/src/components/form/JsonInput.tsx b/src/components/form/JsonInput.tsx index 302de1ed25..3796c11395 100644 --- a/src/components/form/JsonInput.tsx +++ b/src/components/form/JsonInput.tsx @@ -15,7 +15,6 @@ * limitations under the License. */ import { JsonInput, type JsonInputProps } from '@mantine/core'; -import { omit } from 'rambdax'; import { useMemo } from 'react'; import { type FieldValues, @@ -29,17 +28,15 @@ export type FormItemJsonInputProps = UseControllerProps & JsonInputProps & { toObject?: boolean; - objValue?: unknown; }; export const FormItemJsonInput = ( props: FormItemJsonInputProps ) => { - const { objValue = {} } = props; const { controllerProps, restProps: { toObject, ...restProps }, - } = genControllerProps(props, props.toObject ? objValue : ''); + } = genControllerProps(props, props.toObject ? {} : ''); const { field: { value: rawVal, onChange: fOnChange, ...restField }, fieldState, @@ -48,9 +45,9 @@ export const FormItemJsonInput = ( if (!toObject) return rawVal; if (typeof rawVal === 'string') return rawVal; const val = JSON.stringify(rawVal, null, 2); - if (val === JSON.stringify(objValue)) return ''; + if (val === '{}') return ''; return val; - }, [rawVal, toObject, objValue]); + }, [rawVal, toObject]); return ( ( try { res = JSON.parse(val); } catch { - res = val.length === 0 ? objValue : val; + res = val.length === 0 ? {} : val; } } fOnChange(res); @@ -72,7 +69,7 @@ export const FormItemJsonInput = ( autosize resize="vertical" {...restField} - {...omit(['objValue'], restProps)} + {...restProps} /> ); }; diff --git a/src/routes/stream_routes/add.tsx b/src/routes/stream_routes/add.tsx index 9131aa76c6..bec082e8e5 100644 --- a/src/routes/stream_routes/add.tsx +++ b/src/routes/stream_routes/add.tsx @@ -32,7 +32,6 @@ import { FormTOCBox } from '@/components/form-slice/FormSection'; import PageHeader from '@/components/page/PageHeader'; import { req } from '@/config/req'; import type { APISIXType } from '@/types/schema/apisix'; -import { produceRmUpstreamWhenHas } from '@/utils/form-producer'; import { pipeProduce } from '@/utils/producer'; type Props = { @@ -46,10 +45,7 @@ export const StreamRouteAddForm = (props: Props) => { const postStreamRoute = useMutation({ mutationFn: (d: StreamRoutePostType) => - postStreamRouteReq( - req, - pipeProduce(produceRmUpstreamWhenHas('service_id'))(d) - ), + postStreamRouteReq(req, pipeProduce()(d)), async onSuccess(res) { notifications.show({ message: t('info.add.success', { name: t('streamRoutes.singular') }), diff --git a/src/routes/stream_routes/detail.$id.tsx b/src/routes/stream_routes/detail.$id.tsx index e9f641a7f4..4232e95d03 100644 --- a/src/routes/stream_routes/detail.$id.tsx +++ b/src/routes/stream_routes/detail.$id.tsx @@ -39,7 +39,6 @@ import PageHeader from '@/components/page/PageHeader'; import { API_STREAM_ROUTES } from '@/config/constant'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; -import { produceRmUpstreamWhenHas } from '@/utils/form-producer'; import { pipeProduce } from '@/utils/producer'; type Props = { @@ -71,10 +70,7 @@ const StreamRouteDetailForm = (props: Props) => { const putStreamRoute = useMutation({ mutationFn: (d: APISIXType['StreamRoute']) => - putStreamRouteReq( - req, - pipeProduce(produceRmUpstreamWhenHas('service_id'))(d) - ), + putStreamRouteReq(req, pipeProduce()(d)), async onSuccess() { notifications.show({ message: t('info.edit.success', { name: t('streamRoutes.singular') }), diff --git a/src/routes/stream_routes/index.tsx b/src/routes/stream_routes/index.tsx index 0c7cf29d89..3fb5247ba3 100644 --- a/src/routes/stream_routes/index.tsx +++ b/src/routes/stream_routes/index.tsx @@ -62,15 +62,9 @@ export const StreamRouteList = (props: StreamRouteListProps) => { valueType: 'text', }, { - dataIndex: ['value', 'server_addr'], - title: t('form.streamRoutes.serverAddr'), - key: 'server_addr', - valueType: 'text', - }, - { - dataIndex: ['value', 'server_port'], - title: t('form.streamRoutes.serverPort'), - key: 'server_port', + dataIndex: ['value', 'name'], + title: t('form.basic.name'), + key: 'name', valueType: 'text', }, { diff --git a/src/types/schema/apisix/stream_routes.ts b/src/types/schema/apisix/stream_routes.ts index 77757abe0d..fcea623b5a 100644 --- a/src/types/schema/apisix/stream_routes.ts +++ b/src/types/schema/apisix/stream_routes.ts @@ -28,8 +28,8 @@ const StreamRouteProtocolLoggerItem = z.object({ const StreamRouteProtocol = z.object({ name: z.string(), superior_id: z.string(), - conf: z.object({}).optional(), - logger: z.array(StreamRouteProtocolLoggerItem).optional(), + conf: z.object({}), + logger: z.array(StreamRouteProtocolLoggerItem), }); const StreamRoute = z From 9d50d4ba660d8521b3a6424792fea39d4796a9c4 Mon Sep 17 00:00:00 2001 From: Skye Young Date: Tue, 10 Jun 2025 17:21:55 +0800 Subject: [PATCH 07/14] test: stream_routes in services --- e2e/tests/services.stream_routes.crud.spec.ts | 249 ++++++++++++++++++ e2e/tests/services.stream_routes.list.spec.ts | 162 ++++++++++++ src/apis/stream_routes.ts | 24 +- .../form-slice/FormPartStreamRoute/index.tsx | 2 + src/components/form/JsonInput.tsx | 13 +- src/routes/stream_routes/add.tsx | 6 +- src/routes/stream_routes/detail.$id.tsx | 6 +- src/routes/stream_routes/index.tsx | 12 +- src/types/schema/apisix/stream_routes.ts | 4 +- 9 files changed, 465 insertions(+), 13 deletions(-) create mode 100644 e2e/tests/services.stream_routes.crud.spec.ts create mode 100644 e2e/tests/services.stream_routes.list.spec.ts 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 0000000000..a53f641b38 --- /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 0000000000..e74ebfd2e6 --- /dev/null +++ b/e2e/tests/services.stream_routes.list.spec.ts @@ -0,0 +1,162 @@ +/** + * 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 { expect } from '@playwright/test'; + +import { deleteAllServices, postServiceReq } from '@/apis/services'; +import { + deleteAllStreamRoutes, + postStreamRouteReq, +} from '@/apis/stream_routes'; + +const serviceName = randomId('test-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, + }, +]; + +let testServiceId: 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 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); + } +}); + +test.afterAll(async () => { + await deleteAllStreamRoutes(e2eReq); + await deleteAllServices(e2eReq); +}); + +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); + }); +}); diff --git a/src/apis/stream_routes.ts b/src/apis/stream_routes.ts index 0dbf09d308..c01f58db59 100644 --- a/src/apis/stream_routes.ts +++ b/src/apis/stream_routes.ts @@ -18,7 +18,11 @@ import type { AxiosInstance } from 'axios'; import type { StreamRoutePostType } from '@/components/form-slice/FormPartStreamRoute/schema'; -import { API_STREAM_ROUTES } from '@/config/constant'; +import { + API_STREAM_ROUTES, + PAGE_SIZE_MAX, + PAGE_SIZE_MIN, +} from '@/config/constant'; import type { APISIXType } from '@/types/schema/apisix'; import type { WithServiceIdFilter } from './routes'; @@ -59,3 +63,21 @@ export const postStreamRouteReq = ( API_STREAM_ROUTES, data ); + +export const deleteAllStreamRoutes = async (req: AxiosInstance) => { + const totalRes = await getStreamRouteListReq(req, { + page: 1, + page_size: PAGE_SIZE_MIN, + }); + const total = totalRes.total; + if (total === 0) return; + for (let times = Math.ceil(total / PAGE_SIZE_MAX); times > 0; times--) { + const res = await getStreamRouteListReq(req, { + page: 1, + page_size: PAGE_SIZE_MAX, + }); + await Promise.all( + res.list.map((d) => req.delete(`${API_STREAM_ROUTES}/${d.value.id}`)) + ); + } +}; diff --git a/src/components/form-slice/FormPartStreamRoute/index.tsx b/src/components/form-slice/FormPartStreamRoute/index.tsx index 8b6bf11410..5c0bdf5ae0 100644 --- a/src/components/form-slice/FormPartStreamRoute/index.tsx +++ b/src/components/form-slice/FormPartStreamRoute/index.tsx @@ -81,12 +81,14 @@ const FormSectionStreamRouteProtocol = () => { control={control} name="protocol.conf" label={t('form.streamRoutes.protocol.conf')} + toObject /> ); diff --git a/src/components/form/JsonInput.tsx b/src/components/form/JsonInput.tsx index 3796c11395..302de1ed25 100644 --- a/src/components/form/JsonInput.tsx +++ b/src/components/form/JsonInput.tsx @@ -15,6 +15,7 @@ * limitations under the License. */ import { JsonInput, type JsonInputProps } from '@mantine/core'; +import { omit } from 'rambdax'; import { useMemo } from 'react'; import { type FieldValues, @@ -28,15 +29,17 @@ export type FormItemJsonInputProps = UseControllerProps & JsonInputProps & { toObject?: boolean; + objValue?: unknown; }; export const FormItemJsonInput = ( props: FormItemJsonInputProps ) => { + const { objValue = {} } = props; const { controllerProps, restProps: { toObject, ...restProps }, - } = genControllerProps(props, props.toObject ? {} : ''); + } = genControllerProps(props, props.toObject ? objValue : ''); const { field: { value: rawVal, onChange: fOnChange, ...restField }, fieldState, @@ -45,9 +48,9 @@ export const FormItemJsonInput = ( if (!toObject) return rawVal; if (typeof rawVal === 'string') return rawVal; const val = JSON.stringify(rawVal, null, 2); - if (val === '{}') return ''; + if (val === JSON.stringify(objValue)) return ''; return val; - }, [rawVal, toObject]); + }, [rawVal, toObject, objValue]); return ( ( try { res = JSON.parse(val); } catch { - res = val.length === 0 ? {} : val; + res = val.length === 0 ? objValue : val; } } fOnChange(res); @@ -69,7 +72,7 @@ export const FormItemJsonInput = ( autosize resize="vertical" {...restField} - {...restProps} + {...omit(['objValue'], restProps)} /> ); }; diff --git a/src/routes/stream_routes/add.tsx b/src/routes/stream_routes/add.tsx index bec082e8e5..9131aa76c6 100644 --- a/src/routes/stream_routes/add.tsx +++ b/src/routes/stream_routes/add.tsx @@ -32,6 +32,7 @@ import { FormTOCBox } from '@/components/form-slice/FormSection'; import PageHeader from '@/components/page/PageHeader'; import { req } from '@/config/req'; import type { APISIXType } from '@/types/schema/apisix'; +import { produceRmUpstreamWhenHas } from '@/utils/form-producer'; import { pipeProduce } from '@/utils/producer'; type Props = { @@ -45,7 +46,10 @@ export const StreamRouteAddForm = (props: Props) => { const postStreamRoute = useMutation({ mutationFn: (d: StreamRoutePostType) => - postStreamRouteReq(req, pipeProduce()(d)), + postStreamRouteReq( + req, + pipeProduce(produceRmUpstreamWhenHas('service_id'))(d) + ), async onSuccess(res) { notifications.show({ message: t('info.add.success', { name: t('streamRoutes.singular') }), diff --git a/src/routes/stream_routes/detail.$id.tsx b/src/routes/stream_routes/detail.$id.tsx index 4232e95d03..e9f641a7f4 100644 --- a/src/routes/stream_routes/detail.$id.tsx +++ b/src/routes/stream_routes/detail.$id.tsx @@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader'; import { API_STREAM_ROUTES } from '@/config/constant'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; +import { produceRmUpstreamWhenHas } from '@/utils/form-producer'; import { pipeProduce } from '@/utils/producer'; type Props = { @@ -70,7 +71,10 @@ const StreamRouteDetailForm = (props: Props) => { const putStreamRoute = useMutation({ mutationFn: (d: APISIXType['StreamRoute']) => - putStreamRouteReq(req, pipeProduce()(d)), + putStreamRouteReq( + req, + pipeProduce(produceRmUpstreamWhenHas('service_id'))(d) + ), async onSuccess() { notifications.show({ message: t('info.edit.success', { name: t('streamRoutes.singular') }), diff --git a/src/routes/stream_routes/index.tsx b/src/routes/stream_routes/index.tsx index 3fb5247ba3..0c7cf29d89 100644 --- a/src/routes/stream_routes/index.tsx +++ b/src/routes/stream_routes/index.tsx @@ -62,9 +62,15 @@ export const StreamRouteList = (props: StreamRouteListProps) => { valueType: 'text', }, { - dataIndex: ['value', 'name'], - title: t('form.basic.name'), - key: 'name', + dataIndex: ['value', 'server_addr'], + title: t('form.streamRoutes.serverAddr'), + key: 'server_addr', + valueType: 'text', + }, + { + dataIndex: ['value', 'server_port'], + title: t('form.streamRoutes.serverPort'), + key: 'server_port', valueType: 'text', }, { diff --git a/src/types/schema/apisix/stream_routes.ts b/src/types/schema/apisix/stream_routes.ts index fcea623b5a..77757abe0d 100644 --- a/src/types/schema/apisix/stream_routes.ts +++ b/src/types/schema/apisix/stream_routes.ts @@ -28,8 +28,8 @@ const StreamRouteProtocolLoggerItem = z.object({ const StreamRouteProtocol = z.object({ name: z.string(), superior_id: z.string(), - conf: z.object({}), - logger: z.array(StreamRouteProtocolLoggerItem), + conf: z.object({}).optional(), + logger: z.array(StreamRouteProtocolLoggerItem).optional(), }); const StreamRoute = z From 426f54b03850ad25cef81c76c19dc892cb22f827 Mon Sep 17 00:00:00 2001 From: Skye Young Date: Thu, 12 Jun 2025 15:39:44 +0800 Subject: [PATCH 08/14] chore: rm stream_routes pom --- e2e/pom/services.ts | 41 ----------------------------------------- 1 file changed, 41 deletions(-) diff --git a/e2e/pom/services.ts b/e2e/pom/services.ts index 60334c78f4..7ac701cf66 100644 --- a/e2e/pom/services.ts +++ b/e2e/pom/services.ts @@ -29,11 +29,6 @@ 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 = { @@ -86,36 +81,6 @@ 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 = { @@ -127,12 +92,6 @@ 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 = { From d4dbe1cc270c30b806fc98537585b98c3411fc03 Mon Sep 17 00:00:00 2001 From: Skye Young Date: Thu, 12 Jun 2025 16:44:23 +0800 Subject: [PATCH 09/14] feat: add only show routes with service_id case --- e2e/tests/services.routes.list.spec.ts | 66 ++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/e2e/tests/services.routes.list.spec.ts b/e2e/tests/services.routes.list.spec.ts index 51c451ff84..ac8ad4f4b0 100644 --- a/e2e/tests/services.routes.list.spec.ts +++ b/e2e/tests/services.routes.list.spec.ts @@ -14,8 +14,8 @@ * 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 type { HttpMethod } from '@e2e/type'; import { randomId } from '@e2e/utils/common'; import { e2eReq } from '@e2e/utils/req'; import { test } from '@e2e/utils/test'; @@ -23,9 +23,10 @@ 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 routes = [ +const routes: APISIXType['Route'][] = [ { name: randomId('route1'), uri: '/api/v1/test1', @@ -41,11 +42,17 @@ const routes = [ uri: '/api/v1/test3', methods: ['PUT'], }, -] as Array<{ - name: string; - uri: string; - methods: HttpMethod[]; -}>; +]; + +// 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 }], + }, +}; let testServiceId: string; const createdRoutes: string[] = []; @@ -65,13 +72,14 @@ test.beforeAll(async () => { // Create test routes under the service for (const route of routes) { const routeResponse = await postRouteReq(e2eReq, { - name: route.name, - uri: route.uri, - methods: route.methods, + ...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); }); test.afterAll(async () => { @@ -79,6 +87,44 @@ test.afterAll(async () => { await deleteAllServices(e2eReq); }); +test('should only show routes with service_id', async ({ page }) => { + await test.step('routes in services detail page should only show routes with 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); + + await expect( + page.getByRole('cell', { name: upstreamRoute.name }) + ).toBeHidden(); + for (const route of routes) { + await expect(page.getByRole('cell', { name: route.name })).toBeVisible(); + } + await expect( + page.getByRole('cell', { name: upstreamRoute.name }) + ).toBeHidden(); + }); + + await test.step('without service_id routes should still exist in the routes list', async () => { + await routesPom.toIndex(page); + await routesPom.isIndexPage(page); + + await expect( + page.getByRole('cell', { name: upstreamRoute.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); From 4064da269d5f04958444712dc6f41a32b55e5b75 Mon Sep 17 00:00:00 2001 From: Skye Young Date: Thu, 12 Jun 2025 17:00:10 +0800 Subject: [PATCH 10/14] test: add not show other service route case --- e2e/tests/services.routes.list.spec.ts | 41 ++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/e2e/tests/services.routes.list.spec.ts b/e2e/tests/services.routes.list.spec.ts index ac8ad4f4b0..abb6b0679d 100644 --- a/e2e/tests/services.routes.list.spec.ts +++ b/e2e/tests/services.routes.list.spec.ts @@ -26,6 +26,7 @@ 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'), @@ -54,7 +55,15 @@ const upstreamRoute: APISIXType['Route'] = { }, }; +// 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 () => { @@ -69,6 +78,14 @@ test.beforeAll(async () => { 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, { @@ -80,6 +97,12 @@ test.beforeAll(async () => { // 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 () => { @@ -87,8 +110,8 @@ test.afterAll(async () => { await deleteAllServices(e2eReq); }); -test('should only show routes with service_id', async ({ page }) => { - await test.step('routes in services detail page should only show routes with service_id', async () => { +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); @@ -101,24 +124,31 @@ test('should only show routes with service_id', async ({ 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 expect( - page.getByRole('cell', { name: upstreamRoute.name }) - ).toBeHidden(); }); 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(); } @@ -206,3 +236,4 @@ test('should display routes list under service', async ({ page }) => { await expect(tableRows).toHaveCount(routes.length); }); }); + From de22f071e4cfc385745a1d45efeb2974aa79fdfb Mon Sep 17 00:00:00 2001 From: Skye Young Date: Thu, 12 Jun 2025 17:39:05 +0800 Subject: [PATCH 11/14] feat: add services stream_routes pom --- e2e/pom/services.ts | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/e2e/pom/services.ts b/e2e/pom/services.ts index 7ac701cf66..60334c78f4 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 = { From c8b14582bf497aea31fa32326f36f3891fe0b5f2 Mon Sep 17 00:00:00 2001 From: Skye Young Date: Fri, 13 Jun 2025 11:22:19 +0800 Subject: [PATCH 12/14] test: stream_routes in services --- e2e/tests/services.stream_routes.list.spec.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/e2e/tests/services.stream_routes.list.spec.ts b/e2e/tests/services.stream_routes.list.spec.ts index e74ebfd2e6..2552926d96 100644 --- a/e2e/tests/services.stream_routes.list.spec.ts +++ b/e2e/tests/services.stream_routes.list.spec.ts @@ -18,6 +18,7 @@ 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'; @@ -27,6 +28,7 @@ import { } from '@/apis/stream_routes'; const serviceName = randomId('test-service'); +const anotherServiceName = randomId('another-service'); const streamRoutes = [ { server_addr: '127.0.0.1', @@ -42,7 +44,23 @@ const streamRoutes = [ }, ]; +// Stream route that uses upstream directly instead of service_id +const upstreamStreamRoute = { + server_addr: '127.0.0.10', + 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 () => { @@ -57,6 +75,14 @@ test.beforeAll(async () => { 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, { @@ -66,6 +92,15 @@ test.beforeAll(async () => { }); 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 () => { @@ -73,6 +108,61 @@ test.afterAll(async () => { 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 }) + ).toBeVisible(); + } + }); +}); + test('should display stream routes list under service', async ({ page }) => { // Navigate to service detail page await servicesPom.toIndex(page); @@ -160,3 +250,4 @@ test('should display stream routes list under service', async ({ page }) => { await expect(tableRows).toHaveCount(streamRoutes.length); }); }); + From 94c7c065cb6e9ad3cd41304b11a1e0f225130606 Mon Sep 17 00:00:00 2001 From: Skye Young Date: Fri, 13 Jun 2025 12:01:33 +0800 Subject: [PATCH 13/14] fix: use exact match --- e2e/tests/services.stream_routes.list.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/tests/services.stream_routes.list.spec.ts b/e2e/tests/services.stream_routes.list.spec.ts index 2552926d96..07044fbed8 100644 --- a/e2e/tests/services.stream_routes.list.spec.ts +++ b/e2e/tests/services.stream_routes.list.spec.ts @@ -46,7 +46,7 @@ const streamRoutes = [ // Stream route that uses upstream directly instead of service_id const upstreamStreamRoute = { - server_addr: '127.0.0.10', + server_addr: '127.0.0.40', server_port: 9090, upstream: { nodes: [{ host: 'example.com', port: 80, weight: 100 }], @@ -157,7 +157,7 @@ test('should only show stream routes with current service_id', async ({ ).toBeVisible(); for (const streamRoute of streamRoutes) { await expect( - page.getByRole('cell', { name: streamRoute.server_addr }) + page.getByRole('cell', { name: streamRoute.server_addr, exact: true }) ).toBeVisible(); } }); From 2a58351be291f37a009548ec15a7db7bbbe4618f Mon Sep 17 00:00:00 2001 From: Skye Young Date: Mon, 16 Jun 2025 09:55:06 +0800 Subject: [PATCH 14/14] chore: rollback useless change --- src/components/form/JsonInput.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/form/JsonInput.tsx b/src/components/form/JsonInput.tsx index 302de1ed25..206289eb84 100644 --- a/src/components/form/JsonInput.tsx +++ b/src/components/form/JsonInput.tsx @@ -25,12 +25,11 @@ import { import { genControllerProps } from './util'; -export type FormItemJsonInputProps = - UseControllerProps & - JsonInputProps & { - toObject?: boolean; - objValue?: unknown; - }; +export type FormItemJsonInputProps = UseControllerProps & + JsonInputProps & { + toObject?: boolean; + objValue?: unknown; + }; export const FormItemJsonInput = ( props: FormItemJsonInputProps