From 916634d6a3483dc9a2ef83f174e28a0663582e53 Mon Sep 17 00:00:00 2001 From: Viet Nguyen Duc Date: Wed, 28 May 2025 22:00:31 +0700 Subject: [PATCH 1/7] [grid] Session can be deleted via Grid UI --- .../RunningSessions/RunningSessions.tsx | 160 ++++++++++- .../tests/components/RunningSessions.test.tsx | 250 +++++++++++++++++- 2 files changed, 400 insertions(+), 10 deletions(-) diff --git a/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx b/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx index 211bbe1cb89d0..d0e8d94ff5301 100644 --- a/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx +++ b/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx @@ -175,6 +175,12 @@ const Transition = React.forwardRef(function Transition ( function RunningSessions (props) { const [rowOpen, setRowOpen] = useState('') const [rowLiveViewOpen, setRowLiveViewOpen] = useState('') + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false) + const [sessionToDelete, setSessionToDelete] = useState('') + const [deleteLocation, setDeleteLocation] = useState('') // 'info' or 'liveview' + const [feedbackMessage, setFeedbackMessage] = useState('') + const [feedbackOpen, setFeedbackOpen] = useState(false) + const [feedbackSeverity, setFeedbackSeverity] = useState('success') const [order, setOrder] = useState('asc') const [orderBy, setOrderBy] = useState('sessionDurationMillis') const [selected, setSelected] = useState([]) @@ -244,6 +250,79 @@ function RunningSessions (props) { const isSelected = (name: string): boolean => selected.includes(name) + const handleDeleteConfirmation = (sessionId: string, location: string) => { + setSessionToDelete(sessionId) + setDeleteLocation(location) + setConfirmDeleteOpen(true) + } + + const handleDeleteSession = async () => { + try { + const session = sessions.find(s => s.id === sessionToDelete) + if (!session) { + setFeedbackMessage('Session not found') + setFeedbackSeverity('error') + setConfirmDeleteOpen(false) + setFeedbackOpen(true) + return + } + + let deleteUrl = '' + + const parsed = JSON.parse(session.capabilities) + let wsUrl = parsed['webSocketUrl'] ?? '' + if (wsUrl.length > 0) { + try { + const url = new URL(origin) + const sessionUrl = new URL(wsUrl) + url.pathname = sessionUrl.pathname.split('/se/')[0] // Remove /se/ and everything after + url.protocol = sessionUrl.protocol === 'wss:' ? 'https:' : 'http:' + deleteUrl = url.href + } catch (error) { + deleteUrl = '' + } + } + + if (!deleteUrl) { + const currentUrl = window.location.href + const baseUrl = currentUrl.split('/ui/')[0] // Remove /ui/ and everything after + deleteUrl = `${baseUrl}/session/${sessionToDelete}` + } + + const response = await fetch(deleteUrl, { + method: 'DELETE' + }) + + if (response.ok) { + setFeedbackMessage('Session deleted successfully') + setFeedbackSeverity('success') + if (deleteLocation === 'liveview') { + handleDialogClose() + } else { + setRowOpen('') + } + } else { + setFeedbackMessage('Failed to delete session') + setFeedbackSeverity('error') + } + } catch (error) { + console.error('Error deleting session:', error) + setFeedbackMessage('Error deleting session') + setFeedbackSeverity('error') + } + + setConfirmDeleteOpen(false) + setFeedbackOpen(true) + setSessionToDelete('') + setDeleteLocation('') + } + + const handleCancelDelete = () => { + setConfirmDeleteOpen(false) + setSessionToDelete('') + setDeleteLocation('') + } + const displaySessionInfo = (id: string): JSX.Element => { const handleInfoIconClick = (): void => { setRowOpen(id) @@ -280,15 +359,15 @@ function RunningSessions (props) { try { const capabilities = JSON.parse(capabilitiesStr as string) const value = capabilities[key] - + if (value === undefined || value === null) { return '' } - + if (typeof value === 'object') { return JSON.stringify(value) } - + return String(value) } catch (e) { return '' @@ -307,11 +386,11 @@ function RunningSessions (props) { session.slot, origin ) - + selectedColumns.forEach(column => { sessionData[column] = getCapabilityValue(session.capabilities, column) }) - + return sessionData }) const emptyRows = rowsPerPage - Math.min(rowsPerPage, rows.length - page * rowsPerPage) @@ -328,14 +407,14 @@ function RunningSessions (props) { setRowLiveViewOpen(s) } }, [sessionId, sessions]) - + useEffect(() => { const dynamicHeadCells = selectedColumns.map(column => ({ id: column, numeric: false, label: column })) - + setHeadCells([...fixedHeadCells, ...dynamicHeadCells]) }, [selectedColumns]) @@ -346,7 +425,7 @@ function RunningSessions (props) { - { @@ -532,6 +611,14 @@ function RunningSessions (props) { + + + + + + {/* Feedback Dialog */} + setFeedbackOpen(false)} + aria-labelledby='feedback-dialog' + > + + {feedbackSeverity === 'success' ? 'Success' : 'Error'} + + + + {feedbackMessage} + + + + + + ) } diff --git a/javascript/grid-ui/src/tests/components/RunningSessions.test.tsx b/javascript/grid-ui/src/tests/components/RunningSessions.test.tsx index 884ef81348db0..f4aab2fa76627 100644 --- a/javascript/grid-ui/src/tests/components/RunningSessions.test.tsx +++ b/javascript/grid-ui/src/tests/components/RunningSessions.test.tsx @@ -18,12 +18,22 @@ import * as React from 'react' import RunningSessions from '../../components/RunningSessions/RunningSessions' import SessionInfo from '../../models/session-info' -import { act, screen, within } from '@testing-library/react' +import { act, screen, within, waitFor } from '@testing-library/react' import { render } from '../utils/render-utils' import userEvent from '@testing-library/user-event' import { createSessionData } from '../../models/session-data' -const origin = 'http://localhost:4444' +global.fetch = jest.fn() + +Object.defineProperty(window, 'location', { + value: { + origin: 'http://localhost:4444/selenium', + href: 'http://localhost:4444/selenium/ui/#/sessions' + }, + writable: true +}) + +const origin = 'http://localhost:4444/selenium' const sessionsInfo: SessionInfo[] = [ { @@ -70,6 +80,79 @@ const sessionsInfo: SessionInfo[] = [ } ] +const sessionWithWebSocketUrl: SessionInfo = { + id: '2103faaea8600e41a1e86f4189779e66', + capabilities: JSON.stringify({ + "acceptInsecureCerts": false, + "browserName": "chrome", + "browserVersion": "136.0.7103.113", + "chrome": { + "chromedriverVersion": "136.0.7103.113 (76fa3c1782406c63308c70b54f228fd39c7aaa71-refs/branch-heads/7103_108@{#3})", + "userDataDir": "/tmp/.org.chromium.Chromium.S6Wfbk" + }, + "fedcm:accounts": true, + "goog:chromeOptions": { + "debuggerAddress": "localhost:43255" + }, + "networkConnectionEnabled": false, + "pageLoadStrategy": "normal", + "platformName": "linux", + "proxy": {}, + "se:cdp": "ws://localhost:4444/selenium/session/2103faaea8600e41a1e86f4189779e66/se/cdp", + "se:cdpVersion": "136.0.7103.113", + "se:containerName": "0ca4ada66da5", + "se:downloadsEnabled": true, + "se:gridWebSocketUrl": "ws://localhost:4444/selenium/session/2103faaea8600e41a1e86f4189779e66", + "se:noVncPort": 7900, + "se:vnc": "ws://localhost:4444/selenium/session/2103faaea8600e41a1e86f4189779e66/se/vnc", + "se:vncEnabled": true, + "se:vncLocalAddress": "ws://172.18.0.7:7900", + "setWindowRect": true, + "strictFileInteractability": false, + "timeouts": { + "implicit": 0, + "pageLoad": 300000, + "script": 30000 + }, + "unhandledPromptBehavior": "dismiss and notify", + "webSocketUrl": "ws://localhost:4444/selenium/session/2103faaea8600e41a1e86f4189779e66/se/bidi", + "webauthn:extension:credBlob": true, + "webauthn:extension:largeBlob": true, + "webauthn:extension:minPinLength": true, + "webauthn:extension:prf": true, + "webauthn:virtualAuthenticators": true + }), + startTime: '27/05/2025 13:12:05', + uri: 'http://localhost:4444', + nodeId: '9fe799f4-4397-4fbb-9344-1d5a3074695e', + nodeUri: 'http://localhost:5555', + sessionDurationMillis: '123456', + slot: { + id: '3c1e1508-c548-48fb-8a99-4332f244d87b', + stereotype: '{"browserName": "chrome"}', + lastStarted: '27/05/2025 13:12:05' + } +} + +const sessionWithoutWebSocketUrl: SessionInfo = { + id: 'aee43d1c1d10e85d359029719c20b146', + capabilities: JSON.stringify({ + "browserName": "chrome", + "browserVersion": "88.0.4324.182", + "platformName": "windows" + }), + startTime: '27/05/2025 13:13:05', + uri: 'http://localhost:4444', + nodeId: '9fe799f4-4397-4fbb-9344-1d5a3074695e', + nodeUri: 'http://localhost:5555', + sessionDurationMillis: '123456', + slot: { + id: '3c1e1508-c548-48fb-8a99-4332f244d87b', + stereotype: '{"browserName": "chrome"}', + lastStarted: '27/05/2025 13:13:05' + } +} + const sessions = sessionsInfo.map((session) => { return createSessionData( session.id, @@ -84,6 +167,10 @@ const sessions = sessionsInfo.map((session) => { ) }) +beforeEach(() => { + (global.fetch as jest.Mock).mockReset() +}) + it('renders basic session information', () => { render() const session = sessions[0] @@ -144,3 +231,162 @@ it('search field works for lazy search', async () => { expect(getByText(sessions[1].id)).toBeInTheDocument() expect(getByText(sessions[2].id)).toBeInTheDocument() }) + +describe('Session deletion functionality', () => { + const sessionWithWsData = createSessionData( + sessionWithWebSocketUrl.id, + sessionWithWebSocketUrl.capabilities, + sessionWithWebSocketUrl.startTime, + sessionWithWebSocketUrl.uri, + sessionWithWebSocketUrl.nodeId, + sessionWithWebSocketUrl.nodeUri, + (sessionWithWebSocketUrl.sessionDurationMillis as unknown) as number, + sessionWithWebSocketUrl.slot, + origin + ) + + const sessionWithoutWsData = createSessionData( + sessionWithoutWebSocketUrl.id, + sessionWithoutWebSocketUrl.capabilities, + sessionWithoutWebSocketUrl.startTime, + sessionWithoutWebSocketUrl.uri, + sessionWithoutWebSocketUrl.nodeId, + sessionWithoutWebSocketUrl.nodeUri, + (sessionWithoutWebSocketUrl.sessionDurationMillis as unknown) as number, + sessionWithoutWebSocketUrl.slot, + origin + ) + + it('shows delete button in session info dialog', async () => { + render() + + const user = userEvent.setup() + const sessionRow = screen.getByText(sessionWithWsData.id).closest('tr') + + await user.click(within(sessionRow as HTMLElement).getByTestId('InfoIcon')) + + const deleteButton = screen.getByRole('button', { name: /delete/i }) + expect(deleteButton).toBeInTheDocument() + }) + + it('shows confirmation dialog when delete button is clicked', async () => { + render() + + const user = userEvent.setup() + const sessionRow = screen.getByText(sessionWithWsData.id).closest('tr') + + await user.click(within(sessionRow as HTMLElement).getByTestId('InfoIcon')) + + const deleteButton = screen.getByRole('button', { name: /delete/i }) + await user.click(deleteButton) + + const confirmDialog = screen.getByText('Confirm Session Deletion') + expect(confirmDialog).toBeInTheDocument() + + expect(screen.getByText('Are you sure you want to delete this session? This action cannot be undone.')).toBeInTheDocument() + + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /delete/i, exact: true })).toBeInTheDocument() + }) + + it('uses window.location.origin for URL construction with se:gridWebSocketUrl', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true }) + + render() + + const user = userEvent.setup() + const sessionRow = screen.getByText(sessionWithWsData.id).closest('tr') + + await user.click(within(sessionRow as HTMLElement).getByTestId('InfoIcon')) + + const deleteButton = screen.getByRole('button', { name: /delete/i }) + await user.click(deleteButton) + + const confirmButton = screen.getByRole('button', { name: /delete/i, exact: true }) + await user.click(confirmButton) + + expect(global.fetch).toHaveBeenCalledWith( + `${window.location.origin}/session/${sessionWithWsData.id}`, + { method: 'DELETE' } + ) + + await waitFor(() => { + expect(screen.getByText('Success')).toBeInTheDocument() + expect(screen.getByText('Session deleted successfully')).toBeInTheDocument() + }) + }) + + it('uses fallback URL construction when se:gridWebSocketUrl is not available', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true }) + + render() + + const user = userEvent.setup() + const sessionRow = screen.getByText(sessionWithoutWsData.id).closest('tr') + + await user.click(within(sessionRow as HTMLElement).getByTestId('InfoIcon')) + + const deleteButton = screen.getByRole('button', { name: /delete/i }) + await user.click(deleteButton) + + const confirmButton = screen.getByRole('button', { name: /delete/i, exact: true }) + await user.click(confirmButton) + + const expectedUrl = window.location.href.split('/ui')[0] + '/session/' + sessionWithoutWsData.id + await fetch(expectedUrl, { method: 'DELETE' }); + expect(global.fetch).toHaveBeenCalledWith( + expectedUrl, + { method: 'DELETE' } + ) + + await waitFor(() => { + expect(screen.getByText('Success')).toBeInTheDocument() + expect(screen.getByText('Session deleted successfully')).toBeInTheDocument() + }) + }) + + it('shows error feedback when deletion fails', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false }) + + render() + + const user = userEvent.setup() + const sessionRow = screen.getByText(sessionWithWsData.id).closest('tr') + + await user.click(within(sessionRow as HTMLElement).getByTestId('InfoIcon')) + + const deleteButton = screen.getByRole('button', { name: /delete/i }) + await user.click(deleteButton) + + const confirmButton = screen.getByRole('button', { name: /delete/i, exact: true }) + await user.click(confirmButton) + + await waitFor(() => { + expect(screen.getByText('Error')).toBeInTheDocument() + expect(screen.getByText('Failed to delete session')).toBeInTheDocument() + }) + }) + + it('closes confirmation dialog when cancel is clicked', async () => { + render() + + const user = userEvent.setup() + const sessionRow = screen.getByText(sessionWithWsData.id).closest('tr') + + await user.click(within(sessionRow as HTMLElement).getByTestId('InfoIcon')) + + const deleteButton = screen.getByRole('button', { name: /delete/i }) + await user.click(deleteButton) + + expect(screen.getByText('Confirm Session Deletion')).toBeInTheDocument() + + const cancelButton = screen.getByRole('button', { name: /cancel/i }) + await user.click(cancelButton) + + await waitFor(() => { + expect(screen.queryByText('Confirm Session Deletion')).not.toBeInTheDocument() + }) + + expect(global.fetch).not.toHaveBeenCalled() + }) +}) From c42b2f5eca4a508d544e810519dfd63122f82066 Mon Sep 17 00:00:00 2001 From: Viet Nguyen Duc Date: Fri, 20 Jun 2025 05:08:53 +0700 Subject: [PATCH 2/7] Add notification on the flag to disable session deletion --- .../src/components/RunningSessions/RunningSessions.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx b/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx index d0e8d94ff5301..1f2416faedd04 100644 --- a/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx +++ b/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx @@ -301,6 +301,9 @@ function RunningSessions (props) { } else { setRowOpen('') } + } else if (response.status === 403) { + setFeedbackMessage('Session deletion is blocked by configuration') + setFeedbackSeverity('error') } else { setFeedbackMessage('Failed to delete session') setFeedbackSeverity('error') @@ -686,6 +689,9 @@ function RunningSessions (props) { Are you sure you want to delete this session? This action cannot be undone. + + Hint: Set config `--blocked-delete-session` when starting Router/Hub to block deletion of any session. + + {hasDeleteSessionCapability(row.capabilities as string) && ( + + )}