diff --git a/java/src/org/openqa/selenium/grid/node/config/NodeFlags.java b/java/src/org/openqa/selenium/grid/node/config/NodeFlags.java index bb65259ee3baf..dd90f080d8f6c 100644 --- a/java/src/org/openqa/selenium/grid/node/config/NodeFlags.java +++ b/java/src/org/openqa/selenium/grid/node/config/NodeFlags.java @@ -19,6 +19,7 @@ import static org.openqa.selenium.grid.config.StandardGridRoles.NODE_ROLE; import static org.openqa.selenium.grid.node.config.NodeOptions.DEFAULT_CONNECTION_LIMIT; +import static org.openqa.selenium.grid.node.config.NodeOptions.DEFAULT_DELETE_SESSION_ON_UI; import static org.openqa.selenium.grid.node.config.NodeOptions.DEFAULT_DETECT_DRIVERS; import static org.openqa.selenium.grid.node.config.NodeOptions.DEFAULT_DRAIN_AFTER_SESSION_COUNT; import static org.openqa.selenium.grid.node.config.NodeOptions.DEFAULT_ENABLE_BIDI; @@ -241,6 +242,13 @@ public class NodeFlags implements HasRoles { @ConfigValue(section = NODE_SECTION, name = "drain-after-session-count", example = "1") public int drainAfterSessionCount = DEFAULT_DRAIN_AFTER_SESSION_COUNT; + @Parameter( + names = {"--delete-session-on-ui"}, + arity = 1, + description = "Enable capability to support deleting session on Grid UI. False by default") + @ConfigValue(section = NODE_SECTION, name = "delete-session-on-ui", example = "true") + public Boolean deleteSessionOnUi = DEFAULT_DELETE_SESSION_ON_UI; + @Parameter( names = {"--enable-cdp"}, arity = 1, diff --git a/java/src/org/openqa/selenium/grid/node/config/NodeOptions.java b/java/src/org/openqa/selenium/grid/node/config/NodeOptions.java index 6691650c0ac20..5afc047497960 100644 --- a/java/src/org/openqa/selenium/grid/node/config/NodeOptions.java +++ b/java/src/org/openqa/selenium/grid/node/config/NodeOptions.java @@ -77,6 +77,7 @@ public class NodeOptions { public static final int DEFAULT_SESSION_TIMEOUT = 300; public static final int DEFAULT_DRAIN_AFTER_SESSION_COUNT = 0; public static final int DEFAULT_CONNECTION_LIMIT = 10; + public static final boolean DEFAULT_DELETE_SESSION_ON_UI = false; public static final boolean DEFAULT_ENABLE_CDP = true; public static final boolean DEFAULT_ENABLE_BIDI = true; static final String NODE_SECTION = "node"; @@ -303,6 +304,13 @@ public int getDrainAfterSessionCount() { DEFAULT_DRAIN_AFTER_SESSION_COUNT); } + @VisibleForTesting + boolean isSessionDeletedOnUi() { + return config + .getBool(NODE_SECTION, "delete-session-on-ui") + .orElse(DEFAULT_DELETE_SESSION_ON_UI); + } + @VisibleForTesting boolean isVncEnabled() { List vncEnvVars = DEFAULT_VNC_ENV_VARS; @@ -750,6 +758,10 @@ public Capabilities enhanceStereotype(Capabilities capabilities) { .setCapability("se:vncEnabled", true) .setCapability("se:noVncPort", noVncPort()); } + if (isSessionDeletedOnUi()) { + capabilities = + new PersistentCapabilities(capabilities).setCapability("se:deleteSessionOnUi", true); + } if (isManagedDownloadsEnabled() && canConfigureDownloadsDir(capabilities)) { capabilities = new PersistentCapabilities(capabilities).setCapability(ENABLE_DOWNLOADS, true); } diff --git a/java/test/org/openqa/selenium/grid/node/config/NodeOptionsTest.java b/java/test/org/openqa/selenium/grid/node/config/NodeOptionsTest.java index 904c3cd158874..babd6692aac39 100644 --- a/java/test/org/openqa/selenium/grid/node/config/NodeOptionsTest.java +++ b/java/test/org/openqa/selenium/grid/node/config/NodeOptionsTest.java @@ -764,6 +764,110 @@ void testIsVncEnabledAcceptSingleEnvVar() { assertThat(nodeOptionsEnabled.isVncEnabled()).isFalse(); } + @Test + void deleteSessionOnUiIsEnabledByDefault() { + Config config = new MapConfig(singletonMap("node", singletonMap("detect-drivers", "false"))); + NodeOptions nodeOptions = new NodeOptions(config); + assertThat(nodeOptions.isSessionDeletedOnUi()).isFalse(); + } + + @Test + void deleteSessionOnUiCanBeEnabledExplicitly() { + Config config = + new MapConfig( + singletonMap( + "node", + ImmutableMap.of("detect-drivers", "false", "delete-session-on-ui", "true"))); + NodeOptions nodeOptions = new NodeOptions(config); + assertThat(nodeOptions.isSessionDeletedOnUi()).isTrue(); + } + + @Test + void deleteSessionOnUiCanBeDisabled() { + Config config = + new MapConfig( + singletonMap( + "node", + ImmutableMap.of("detect-drivers", "false", "delete-session-on-ui", "false"))); + NodeOptions nodeOptions = new NodeOptions(config); + assertThat(nodeOptions.isSessionDeletedOnUi()).isFalse(); + } + + @Test + void deleteSessionOnUiCapabilityIsAddedWhenEnabled() { + assumeTrue( + new ChromeDriverInfo().isPresent() || new GeckoDriverInfo().isPresent(), + "A driver needs to be available"); + + Config config = + new MapConfig( + singletonMap( + "node", ImmutableMap.of("detect-drivers", "true", "delete-session-on-ui", "true"))); + + List reported = new ArrayList<>(); + new NodeOptions(config) + .getSessionFactories( + caps -> { + reported.add(caps); + return Collections.singleton(HelperFactory.create(config, caps)); + }); + + assertThat(reported) + .filteredOn(capabilities -> capabilities.getCapability("se:deleteSessionOnUi") != null) + .hasSize(reported.size()); + + assertThat(reported) + .allMatch( + capabilities -> + Boolean.TRUE.equals(capabilities.getCapability("se:deleteSessionOnUi"))); + } + + @Test + void deleteSessionOnUiCapabilityIsNotAddedWhenDisabled() { + assumeTrue( + new ChromeDriverInfo().isPresent() || new GeckoDriverInfo().isPresent(), + "A driver needs to be available"); + + Config config = + new MapConfig( + singletonMap( + "node", + ImmutableMap.of("detect-drivers", "true", "delete-session-on-ui", "false"))); + + List reported = new ArrayList<>(); + new NodeOptions(config) + .getSessionFactories( + caps -> { + reported.add(caps); + return Collections.singleton(HelperFactory.create(config, caps)); + }); + + assertThat(reported) + .filteredOn(capabilities -> capabilities.getCapability("se:deleteSessionOnUi") == null) + .hasSize(reported.size()); + } + + @Test + void deleteSessionOnUiCapabilityIsAddedByDefault() { + assumeTrue( + new ChromeDriverInfo().isPresent() || new GeckoDriverInfo().isPresent(), + "A driver needs to be available"); + + Config config = new MapConfig(singletonMap("node", singletonMap("detect-drivers", "true"))); + + List reported = new ArrayList<>(); + new NodeOptions(config) + .getSessionFactories( + caps -> { + reported.add(caps); + return Collections.singleton(HelperFactory.create(config, caps)); + }); + + assertThat(reported) + .filteredOn(capabilities -> capabilities.getCapability("se:deleteSessionOnUi") == null) + .hasSize(reported.size()); + } + private Condition> supporting(String name) { return new Condition<>( caps -> caps.stream().anyMatch(cap -> name.equals(cap.getBrowserName())), diff --git a/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx b/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx index 211bbe1cb89d0..a47459fc1f948 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,21 +359,30 @@ 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 '' } } + const hasDeleteSessionCapability = (capabilitiesStr: string): boolean => { + try { + const capabilities = JSON.parse(capabilitiesStr as string) + return capabilities['se:deleteSessionOnUi'] === true + } catch (e) { + return false + } + } + const rows = sessions.map((session) => { const sessionData = createSessionData( session.id, @@ -307,11 +395,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 +416,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 +434,7 @@ function RunningSessions (props) { - { @@ -532,6 +620,16 @@ function RunningSessions (props) { + {hasDeleteSessionCapability(row.capabilities as string) && ( + + )} + + + + + {/* 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..11e278097335d 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,81 @@ 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:deleteSessionOnUi": true, + "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", + "se:deleteSessionOnUi": true + }), + 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 +169,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 +233,182 @@ 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 })).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 }) + 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 }) + 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 }) + 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() + }) + + it('does not show delete button when session does not have se:deleteSessionOnUi capability', async () => { + const sessionWithoutDeleteCapability = { + ...sessionWithWsData, + capabilities: JSON.stringify({ + "browserName": "chrome", + "browserVersion": "136.0.7103.113", + "platformName": "linux" + }) + } + + render() + + const user = userEvent.setup() + const sessionRow = screen.getByText(sessionWithoutDeleteCapability.id).closest('tr') + + await user.click(within(sessionRow as HTMLElement).getByTestId('InfoIcon')) + + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument() + }) +})