diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index 2a20fca61..c882cf224 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -8,7 +8,7 @@ import GrpcRequestPane from 'components/RequestPane/GrpcRequestPane/index'; import ResponsePane from 'components/ResponsePane'; import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane'; import { findItemInCollection } from 'utils/collections'; -import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { sendRequest } from 'providers/ReduxStore/slices/collections/actions'; import { updateGqlDocsOpen } from 'providers/ReduxStore/slices/tabs'; import RequestNotFound from './RequestNotFound'; import QueryUrl from 'components/RequestPane/QueryUrl/index'; @@ -298,13 +298,7 @@ const RequestTabPanel = () => { toast.error('Please enter a valid WebSocket URL'); return; } - - if (item.response?.stream?.running) { - dispatch(cancelRequest(item.cancelTokenUid, item, collection)).catch((err) => - toast.custom((t) => toast.dismiss(t.id)} />, { - duration: 5000 - })); - } else if (item.requestState !== 'sending' && item.requestState !== 'queued') { + if (item.requestState !== 'sending' && item.requestState !== 'queued') { dispatch(sendRequest(item, collection.uid)).catch((err) => toast.custom((t) => toast.dismiss(t.id)} />, { duration: 5000 diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 4521d4f57..c1a920516 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -530,6 +530,10 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { return reject(new Error('Collection not found')); } + if (item.response?.stream?.running && item.cancelTokenUid) { + await dispatch(cancelRequest(item.cancelTokenUid, item, collection)); + } + let collectionCopy = cloneDeep(collection); const itemCopy = cloneDeep(item); @@ -638,7 +642,7 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { }; export const cancelRequest = (cancelTokenUid, item, collection) => (dispatch) => { - cancelNetworkRequest(cancelTokenUid) + return cancelNetworkRequest(cancelTokenUid) .then(() => { dispatch( requestCancelled({ diff --git a/packages/bruno-tests/src/index.js b/packages/bruno-tests/src/index.js index 58e467682..256d2cda7 100644 --- a/packages/bruno-tests/src/index.js +++ b/packages/bruno-tests/src/index.js @@ -10,6 +10,7 @@ const redirectRouter = require('./redirect'); const mixRouter = require('./mix'); const wsRouter = require('./ws'); const setupGraphQL = require('./graphql'); +const sseRouter = require('./sse'); const app = new express(); const port = process.env.PORT || 8081; @@ -49,6 +50,7 @@ app.use('/api/echo', echoRouter); app.use('/api/multipart', multipartRouter); app.use('/api/redirect', redirectRouter); app.use('/api/mix', mixRouter); +app.use('/api/sse', sseRouter); app.get('/ping', function (req, res) { return res.send('pong'); diff --git a/packages/bruno-tests/src/sse/index.js b/packages/bruno-tests/src/sse/index.js new file mode 100644 index 000000000..0cd4cfb49 --- /dev/null +++ b/packages/bruno-tests/src/sse/index.js @@ -0,0 +1,58 @@ +const express = require('express'); +const router = express.Router(); + +// Track active SSE connections +let activeConnections = new Set(); +let connectionIdCounter = 0; + +// GET /api/sse/stream - SSE endpoint that streams data +router.get('/stream', (req, res) => { + const connectionId = ++connectionIdCounter; + activeConnections.add(connectionId); + + console.log(`[SSE] Connection ${connectionId} opened. Active: ${activeConnections.size}`); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + }); + + // Send initial connection message + res.write(`data: ${JSON.stringify({ message: 'Connected', connectionId, activeConnections: activeConnections.size })}\n\n`); + + // Send data every 500ms + const interval = setInterval(() => { + const data = { + connectionId, + timestamp: new Date().toISOString(), + activeConnections: activeConnections.size, + seq: Date.now() + }; + res.write(`data: ${JSON.stringify(data)}\n\n`); + }, 500); + + // Handle client disconnect + req.on('close', () => { + clearInterval(interval); + activeConnections.delete(connectionId); + console.log(`[SSE] Connection ${connectionId} closed. Active: ${activeConnections.size}`); + }); +}); + +// GET /api/sse/connections - Returns count of active connections +router.get('/connections', (req, res) => { + res.json({ + activeConnections: activeConnections.size, + connectionIds: Array.from(activeConnections) + }); +}); + +// POST /api/sse/reset - Reset connection tracking (for test cleanup) +router.post('/reset', (req, res) => { + activeConnections.clear(); + connectionIdCounter = 0; + res.json({ message: 'Reset complete', activeConnections: 0 }); +}); + +module.exports = router; diff --git a/tests/sse/fixtures/collection/bruno.json b/tests/sse/fixtures/collection/bruno.json new file mode 100644 index 000000000..1b59d28ff --- /dev/null +++ b/tests/sse/fixtures/collection/bruno.json @@ -0,0 +1,6 @@ +{ + "version": "1", + "name": "sse-test", + "type": "collection", + "ignore": ["node_modules", ".git"] +} diff --git a/tests/sse/fixtures/collection/sse-stream-request.bru b/tests/sse/fixtures/collection/sse-stream-request.bru new file mode 100644 index 000000000..d0ef38316 --- /dev/null +++ b/tests/sse/fixtures/collection/sse-stream-request.bru @@ -0,0 +1,11 @@ +meta { + name: sse-stream-request + type: http + seq: 1 +} + +get { + url: http://localhost:8081/api/sse/stream + body: none + auth: none +} diff --git a/tests/sse/init-user-data/preferences.json b/tests/sse/init-user-data/preferences.json new file mode 100644 index 000000000..872cf5312 --- /dev/null +++ b/tests/sse/init-user-data/preferences.json @@ -0,0 +1,12 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{collectionPath}}" + ], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +} diff --git a/tests/sse/sse-cancellation.spec.ts b/tests/sse/sse-cancellation.spec.ts new file mode 100644 index 000000000..bd2bb4bd6 --- /dev/null +++ b/tests/sse/sse-cancellation.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '../../playwright'; + +test.describe('SSE Connection Cancellation', () => { + test.beforeEach(async ({ pageWithUserData: page }) => { + // Reset SSE connections before each test + await page.request.post('http://localhost:8081/api/sse/reset'); + }); + + test('should close previous SSE connection when resending request', async ({ pageWithUserData: page }) => { + await page.locator('#sidebar-collection-name').click(); + + await page.getByTestId('sidebar-collection-item-row').filter({ hasText: 'sse-stream-request' }).click(); + + await page.getByTestId('send-arrow-icon').waitFor({ state: 'visible' }); + + await page.getByTestId('send-arrow-icon').click(); + + // Poll until the SSE connection is established + await expect.poll(async () => { + const response = await page.request.get('http://localhost:8081/api/sse/connections'); + const data = await response.json(); + return data.connectionIds; + }, { timeout: 5000 }).toStrictEqual([1]); + + // Resend the request (this should cancel the old connection and start a new one) + const resendShortcut = process.platform === 'darwin' ? 'Meta+Enter' : 'Control+Enter'; + await page.keyboard.press(resendShortcut); + + // Poll until the old connection is closed and a new one is established + await expect.poll(async () => { + const response = await page.request.get('http://localhost:8081/api/sse/connections'); + const data = await response.json(); + return data.connectionIds; + }, { timeout: 5000 }).toStrictEqual([2]); + }); +});