diff --git a/cli/README.md b/cli/README.md index 4296891d..68bd10f9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -50,7 +50,7 @@ Valid output options are: - log (default): method call request and response objects ordered by the timestamp of the objects. - raw: list of method call objects containing both request and response in one object. - mock-overrides: a directory of method calls extrapolated from the raw format. This option converts the raw formats into json files or yaml files. A json file would be the response from a method call that took place when mock-firebolt processed it. Yaml files contain a function that returns a json response depending on input params. Yaml files are only generated if there were multiple of the same method call with different params. -- live: This format operates similarly to the 'log' format with an added real-time feature. As each message is received, it gets immediately written to the specified output file. In addition to accepting regular file paths, the 'live' option also supports WebSocket (WS/WSS) URLs. If a WS/WSS URL is designated as the outputPath, a WebSocket connection is established with the specified URL, and the new messages are dispatched to that connection. Please note that specifying an outputPath is essential for the 'live' option. This path is necessary whether you're sending the live log to a WebSocket URL or saving a live copy of the log file to a local directory. +- live: This format operates similarly to the 'raw' format with an added real-time feature. As each message is received, it gets immediately written to the specified output file. In addition to accepting regular file paths, the 'live' option also supports WebSocket (WS/WSS) URLs. If a WS/WSS URL is designated as the outputPath, a WebSocket connection is established with the specified URL, and the new messages are dispatched to that connection. Please note that specifying an outputPath is essential for the 'live' option. This path is necessary whether you're sending the live log to a WebSocket URL or saving a live copy of the log file to a local directory. To change the output directory of the session recording, use the --sessionOutputPath option with start, stop, or call. This can be done at any time between starting and stopping the recording. For example, --sessionOutputPath ./output/examples will save the recording to the directory specified. The default paths for log and raw formats are ./output/sessions, and for mock-overrides is ./output/mocks. diff --git a/cli/src/usage.mjs b/cli/src/usage.mjs index 53945a52..d04b3033 100644 --- a/cli/src/usage.mjs +++ b/cli/src/usage.mjs @@ -42,7 +42,7 @@ const lines = [ { cmdInfo: "--broadcastEvent ../examples/device-onDeviceNameChanged1.event.json", comment: "Send BroadcastEvent (method, result keys expected)" }, { cmdInfo: "--sequence ../examples/events1.sequence.json ", comment: "Send an event sequence (See examples/device-onDeviceNameChanged.sequence.json)" }, { cmdInfo: "--session start/stop ", comment: "Start/Stop Firebolt session recording" }, - { cmdInfo: "--sessionOutput log|raw|mock-overrides|live ", comment: "Set the output format to; log: (paired time sequence of calls, responses)|raw: similiar to log but not paired with request|mock-overrides: a directory of mock overrides|live: log messages as they are received in real time" }, + { cmdInfo: "--sessionOutput log|raw|mock-overrides|live ", comment: "Set the output format to; log: (paired time sequence of calls, responses)|raw: similiar to log but not paired with request|mock-overrides: a directory of mock overrides|live: log messages as they are received in real time - can also be a websocket url (live only)" }, { cmdInfo: "--sessionOutputPath ../examples/path ", comment: "Specifiy the session output path. Default for 'log' format will be ./output/sessions and ./output/mocks/ for 'mock-overrides'. Can also be a websocket url" }, { cmdInfo: "--getStatus ", comment: "Shows ws connection status of the user"}, { cmdInfo: "--downloadOverrides https://github.com/myOrg/myRepo.git", comment: "Specifies the url of a github repository to clone"}, diff --git a/server/src/sessionManagement.mjs b/server/src/sessionManagement.mjs index 218313a1..6259029d 100644 --- a/server/src/sessionManagement.mjs +++ b/server/src/sessionManagement.mjs @@ -25,56 +25,61 @@ import fs from 'fs'; import yaml from 'js-yaml'; import WebSocket from 'ws'; -class SessionWebSocket { +class SessionHandler { constructor() { + this.mode = null; this.ws = null; + this.stream = null; } - open(dir) { - this.ws = new WebSocket(dir); - this.ws.on('open', () => { - logger.info(`Websocket connection opened: ${dir}`); - }) - } - - close() { - if (this.ws) { - this.ws.close(); - this.ws = null; + // Determine mode based on directory/url + _determineMode(dir) { + const wsRegex = /^(ws(s)?):\/\//i; + if (wsRegex.test(dir)) { + this.mode = 'websocket'; + } else { + this.mode = 'filestream'; } } -} - -class SessionFileStream { - constructor() { - this.stream = null; - } open(dir) { - if (!fs.existsSync(dir)) { - logger.info("Directory does not exist for: " + dir); - fs.mkdirSync(dir, { recursive: true}); + this._determineMode(dir); + + if (this.mode === 'websocket') { + this.ws = new WebSocket(dir); + this.ws.on('open', () => { + logger.info(`Websocket connection opened: ${dir}`); + }); + } else { + if (!fs.existsSync(dir)) { + logger.info("Directory does not exist for: " + dir); + fs.mkdirSync(dir, { recursive: true }); + } + + this.stream = fs.createWriteStream(`${dir}/FireboltCalls_live.log`, { flags: 'a' }); } - - this.stream = fs.createWriteStream(`${dir}/FireboltCalls_live.log`, { flags: 'a' }); } write(data) { - if (this.stream) { + if (this.mode === 'websocket' && this.ws) { + this.ws.send(data); + } else if (this.stream) { this.stream.write(`${data}\n`); } } close() { - if (this.stream) { + if (this.mode === 'websocket' && this.ws) { + this.ws.close(); + this.ws = null; + } else if (this.stream) { this.stream.end(); this.stream = null; } } } -let sessionWebSocket = new SessionWebSocket(); -let sessionFileStream = new SessionFileStream(); +let sessionHandler = new SessionHandler(); class FireboltCall { constructor(methodCall, params) { @@ -112,6 +117,10 @@ class Session { // const sessionStartString = sessionStart.toISOString().replace(/T/, '_').replace(/\..+/, ''); // logger.info(`${sessionStart.toISOString()}`); + // Check if the output path is a WebSocket URL + const wsRegex = /^(ws(s)?):\/\//i; + this.sessionOutputPath = wsRegex.test(this.sessionOutputPath) ? "./sessions/output" : this.sessionOutputPath; + if (!fs.existsSync(this.sessionOutputPath)) { logger.info("Directory does not exist for: " + this.sessionOutputPath) fs.mkdirSync(this.sessionOutputPath, { recursive: true}); @@ -406,13 +415,9 @@ function startRecording(userId) { function stopRecording(userId) { if (isRecording(userId)) { logger.info('Stopping recording'); + sessionRecording[userId].recording = false; const sessionData = sessionRecording[userId].recordedSession.exportSession(); - if (sessionWebSocket.ws) { - sessionWebSocket.close(); - } - if (sessionFileStream.stream) { - sessionFileStream.close(); - } + sessionHandler.close(); delete sessionRecording[userId]; return sessionData; } else { @@ -432,11 +437,7 @@ function addCall(methodCall, params, userId) { sessionRecording[userId].recordedSession.calls.push(call); if (sessionRecording[userId].recordedSession.sessionOutput === "live") { const data = JSON.stringify(call); - if (sessionWebSocket.ws) { - sessionWebSocket.ws.send(data); - } else if (sessionFileStream.stream) { - sessionFileStream.write(data); - } + sessionHandler.write(data); } } } @@ -455,22 +456,12 @@ function getOutputFormat() { } function setOutputDir(dir, userId) { - if (!sessionRecording[userId]) { - logger.error(`No active session found for user: ${userId}`); - return; - } - const wsRegex = /^(ws(s)?):\/\//i; - - if (wsRegex.test(dir)) { - sessionWebSocket.open(dir); - } else { - sessionRecording[userId].recordedSession.sessionOutputPath = dir; - sessionRecording[userId].recordedSession.mockOutputPath = dir; - logger.info(`Setting output path for user ${userId} to:`, sessionRecording[userId].recordedSession.mockOutputPath); - if (sessionRecording[userId].recordedSession.sessionOutput === "live") { - sessionFileStream.open(dir); - } + if (sessionRecording[userId].recordedSession.sessionOutput === "live") { + sessionHandler.open(dir); } + sessionRecording[userId].recordedSession.sessionOutputPath = dir; + sessionRecording[userId].recordedSession.mockOutputPath = dir; + logger.info("Setting output path: " + sessionRecording[userId].recordedSession.mockOutputPath); } function getSessionOutputDir(){ @@ -490,11 +481,7 @@ function updateCallWithResponse(method, result, key, userId) { sessionRecording[userId].recordedSession.calls.concat(...methodCalls); if (sessionRecording[userId].recordedSession.sessionOutput === "live") { const data = JSON.stringify(methodCalls[i]); - if (sessionWebSocket.ws) { - sessionWebSocket.ws.send(data); - } else if (sessionFileStream.stream) { - sessionFileStream.write(data); - } + sessionHandler.write(data); } } } @@ -502,24 +489,14 @@ function updateCallWithResponse(method, result, key, userId) { } // Utility function for unit tests -const setTestEntity = (entityName, mockEntity) => { - switch (entityName) { - case 'websocket': - sessionWebSocket = mockEntity; - break; - case 'filestream': - sessionFileStream = mockEntity; - break; - default: - throw new Error('Unknown entity name'); - } +const setTestEntity = (mockEntity) => { + sessionHandler = mockEntity } export const testExports = { setTestEntity, setOutputDir, - SessionFileStream, - SessionWebSocket + SessionHandler } export { Session, FireboltCall, startRecording, setOutputDir, stopRecording, addCall, isRecording, updateCallWithResponse, setOutputFormat, getOutputFormat, getSessionOutputDir, getMockOutputDir }; \ No newline at end of file diff --git a/server/test/suite/sessionManagement.test.mjs b/server/test/suite/sessionManagement.test.mjs index 178cacbc..2b3ca2ce 100644 --- a/server/test/suite/sessionManagement.test.mjs +++ b/server/test/suite/sessionManagement.test.mjs @@ -133,90 +133,127 @@ describe(`FireboltCall`, () => { }); }); -describe('SessionWebSocket', () => { +describe(`SessionHandler`, () => { afterEach(() => { - jest.restoreAllMocks(); + jest.restoreAllMocks(); }); - test('should initialize without a WebSocket', () => { - const sessionWebSocket = new sessionManagement.testExports.SessionWebSocket(); - expect(sessionWebSocket.ws).toBeNull(); + test(`should initialize without a WebSocket or FileStream`, () => { + const sessionHandler = new sessionManagement.testExports.SessionHandler(); + expect(sessionHandler.ws).toBeNull(); + expect(sessionHandler.stream).toBeNull(); }); - test('should open a WebSocket connection', () => { - const mockWsInstance = { - open: jest.fn(), - close: jest.fn(), - ws: { - open: jest.fn(), - close: jest.fn() - } - }; - - const SessionWebSocket = jest.spyOn(sessionManagement.testExports, 'SessionWebSocket').mockImplementation(() => mockWsInstance); - const dir = 'ws://example.com'; - - const sessionWebSocket = new SessionWebSocket(); - sessionWebSocket.open(dir); - - expect(sessionWebSocket.ws).toBeTruthy(); - expect(sessionWebSocket.open).toHaveBeenCalled(); + describe(`Websocket functionality`, () => { + test(`should open a WebSocket connection`, () => { + const mockSessionHandler = { + ws: { + open: jest.fn(), + send: jest.fn(), + close: jest.fn(), + }, + stream: null, + close: jest.fn(() => { + mockSessionHandler.ws.close() + }), + write: jest.fn(() => { + mockSessionHandler.ws.send() + }), + open: jest.fn(() => { + mockSessionHandler.ws.open() + }), + mode: 'websocket' + }; + + const SessionHandler = jest.spyOn(sessionManagement.testExports, 'SessionHandler') + .mockImplementation(() => mockSessionHandler); + const dir = 'ws://example.com'; + + const sessionHandler = new SessionHandler(); + sessionHandler.open(dir); + + expect(sessionHandler.open).toHaveBeenCalled(); + expect(sessionHandler.ws.open).toHaveBeenCalled(); + }); + + test(`should close a WebSocket connection`, () => { + const mockSessionHandler = { + ws: { + open: jest.fn(), + send: jest.fn(), + close: jest.fn(), + }, + stream: null, + close: jest.fn(() => { + mockSessionHandler.ws = null + }), + write: jest.fn(() => { + mockSessionHandler.ws.send() + }), + open: jest.fn(() => { + mockSessionHandler.ws.open() + }), + mode: 'websocket' + }; + + const SessionHandler = jest.spyOn(sessionManagement.testExports, 'SessionHandler') + .mockImplementation(() => mockSessionHandler); + const dir = 'ws://example.com'; + + const sessionHandler = new SessionHandler(); + sessionHandler.close(dir); + + expect(sessionHandler.close).toHaveBeenCalled(); + expect(sessionHandler.ws).toBeNull(); + }); }); - test('should close a WebSocket connection', () => { - const mockWsInstance = { - open: jest.fn(), - close: jest.fn(() => { - mockWsInstance.ws = null; - }), - ws: { - open: jest.fn(), - close: jest.fn() - } - }; + describe(`FileStream functionality`, () => { + test(`should open a file stream`, () => { + const dir = './some/directory/path'; - const SessionWebSocket = jest.spyOn(sessionManagement.testExports, 'SessionWebSocket').mockImplementation(() => mockWsInstance); - const dir = 'ws://example.com'; - - const sessionWebSocket = new SessionWebSocket(); - sessionWebSocket.close(dir); + const sessionHandler = new sessionManagement.testExports.SessionHandler(); + sessionHandler.open(dir); - expect(sessionWebSocket.ws).toBeNull(); - }); -}); + expect(sessionHandler.stream).toBeTruthy(); + }); -describe(`SessionFileStream`, () => { - test('should initialize without a stream', () => { - const sessionFileStream = new sessionManagement.testExports.SessionFileStream(); - expect(sessionFileStream.stream).toBeNull(); - }); + test(`should write data to the file stream`, () => { + const dir = './some/directory/path'; + const data = 'test data'; - test('should open a file stream', () => { - const sessionFileStream = new sessionManagement.testExports.SessionFileStream(); - const dir = './some/directory/path'; + const mockWriteStream = { + write: jest.fn(), + end: jest.fn() + }; - sessionFileStream.open(dir); - - expect(sessionFileStream.stream).toBeTruthy(); - }); + jest.spyOn(fs, 'createWriteStream').mockReturnValue(mockWriteStream); - test('should write data to the file stream', () => { - const sessionFileStream = new sessionManagement.testExports.SessionFileStream(); - sessionFileStream.open('./some/directory/path'); + const sessionHandler = new sessionManagement.testExports.SessionHandler(); + sessionHandler.open(dir); + sessionHandler.write(data); - const data = 'test data'; - sessionFileStream.write(data); + expect(mockWriteStream.write).toHaveBeenCalledWith(`${data}\n`); + }); - expect(() => sessionFileStream.write(data)).not.toThrow(); - }); + test(`should close the file stream`, () => { + const dir = './some/directory/path'; + + const mockEnd = jest.fn(); + const mockWriteStream = { + write: jest.fn(), + end: mockEnd + }; - test('should close the file stream', () => { - const sessionFileStream = new sessionManagement.testExports.SessionFileStream(); - sessionFileStream.open('./some/directory/path'); + jest.spyOn(fs, 'createWriteStream').mockReturnValue(mockWriteStream); - sessionFileStream.close(); + const sessionHandler = new sessionManagement.testExports.SessionHandler(); + sessionHandler.open(dir); + sessionHandler.close(); - expect(sessionFileStream.stream).toBeNull(); + expect(mockEnd).toHaveBeenCalled(); + expect(sessionHandler.stream).toBeNull(); + }); }); }); @@ -243,28 +280,36 @@ test(`sessionManagement.stopRecording closes the WebSocket connection`, () => { .mockImplementation((dir) => { const wsRegex = /^(ws(s)?):\/\//i; if (wsRegex.test(dir)) { - mockSessionWebSocket.ws.open(); + mockSessionHandler.open(); } else { - mockSessionFileStream.open() + mockSessionHandler.open() } }); - const mockSessionWebSocket = { - ws: { - open: jest.fn(), - close: jest.fn() - }, - close: jest.fn() - }; + const mockSessionHandler = { + ws: { + open: jest.fn(), + send: jest.fn(), + close: jest.fn() + }, + close: jest.fn(() => { + mockSessionHandler.ws.close() + }), + write: jest.fn(() => { + mockSessionHandler.ws.send() + }), + open: jest.fn() + }; + - sessionManagement.testExports.setTestEntity('websocket', mockSessionWebSocket); + sessionManagement.testExports.setTestEntity(mockSessionHandler); sessionManagement.testExports.setOutputDir('ws://example.com'); sessionManagement.startRecording(); sessionManagement.stopRecording(); - expect(mockSessionWebSocket.close).toHaveBeenCalled(); + expect(mockSessionHandler.ws.close).toHaveBeenCalled(); spySetOutputDir.mockRestore(); }); @@ -274,26 +319,34 @@ test(`sessionManagement.stopRecording closes the FileStream connection`, () => { .mockImplementation((dir) => { const wsRegex = /^(ws(s)?):\/\//i; if (wsRegex.test(dir)) { - mockSessionWebSocket.ws.open(); + mockSessionHandler.open(); } else { - mockSessionFileStream.open() + mockSessionHandler.open() } }); - const mockSessionFileStream = { + const mockSessionHandler = { + stream: { + open: jest.fn(), + write: jest.fn(), + close: jest.fn(), + }, + close: jest.fn(() => { + mockSessionHandler.stream.close(); + }), + write: jest.fn(() => { + mockSessionHandler.stream.write(); + }), open: jest.fn(), - write: jest.fn(), - close: jest.fn(), - stream: {} }; - sessionManagement.testExports.setTestEntity('filestream', mockSessionFileStream); + sessionManagement.testExports.setTestEntity(mockSessionHandler); sessionManagement.testExports.setOutputDir('./some/directory/path'); sessionManagement.startRecording(); sessionManagement.stopRecording(); - expect(mockSessionFileStream.close).toHaveBeenCalled(); + expect(mockSessionHandler.stream.close).toHaveBeenCalled(); spySetOutputDir.mockRestore(); }); @@ -315,28 +368,32 @@ test(`sessionManagement.addCall works properly`, () => { expect(result).toBeUndefined(); }); -test(`sessionManagement.addCall calls sessionWebSocket.send`, () => { +test(`sessionManagement.addCall calls websocket`, () => { const spySetOutputDir = jest.spyOn(sessionManagement.testExports, 'setOutputDir') .mockImplementation((dir) => { const wsRegex = /^(ws(s)?):\/\//i; if (wsRegex.test(dir)) { - mockSessionWebSocket.ws.open(); + mockSessionHandler.open(); } else { - mockSessionFileStream.open() + mockSessionHandler.open() } }); - const mockSessionWebSocket = { + const mockSessionHandler = { ws: { open: jest.fn(), send: jest.fn(), close: jest.fn() }, - close: jest.fn() + close: jest.fn(), + write: jest.fn(() => { + mockSessionHandler.ws.send() + }), + open: jest.fn() }; - sessionManagement.testExports.setTestEntity('websocket', mockSessionWebSocket); + sessionManagement.testExports.setTestEntity(mockSessionHandler); sessionManagement.testExports.setOutputDir('ws://example.com'); sessionManagement.startRecording(); @@ -344,31 +401,36 @@ test(`sessionManagement.addCall calls sessionWebSocket.send`, () => { sessionManagement.addCall("methodName", "Parameters"); - expect(mockSessionWebSocket.ws.send).toHaveBeenCalled(); + expect(mockSessionHandler.ws.send).toHaveBeenCalled(); spySetOutputDir.mockRestore(); }); -test(`sessionManagement.addCall calls sessionFileStream.write`, () => { +test(`sessionManagement.addCall calls filestream`, () => { const spySetOutputDir = jest.spyOn(sessionManagement.testExports, 'setOutputDir') .mockImplementation((dir) => { const wsRegex = /^(ws(s)?):\/\//i; if (wsRegex.test(dir)) { - mockSessionWebSocket.ws.open(); + mockSessionHandler.open(); } else { - mockSessionFileStream.open() + mockSessionHandler.open() } }); - const mockSessionFileStream = { - open: jest.fn(), - write: jest.fn(), + const mockSessionHandler = { + stream: { + open: jest.fn(), + write: jest.fn(), + close: jest.fn() + }, close: jest.fn(), - stream: jest.fn() + write: jest.fn(() => { + mockSessionHandler.stream.write() + }), + open: jest.fn() }; - sessionManagement.testExports.setTestEntity('filestream', mockSessionFileStream); - sessionManagement.testExports.setTestEntity('websocket', {}); + sessionManagement.testExports.setTestEntity(mockSessionHandler); sessionManagement.testExports.setOutputDir('./test'); sessionManagement.startRecording(); @@ -376,7 +438,7 @@ test(`sessionManagement.addCall calls sessionFileStream.write`, () => { sessionManagement.addCall("methodName", "Parameters"); - expect(mockSessionFileStream.write).toHaveBeenCalled(); + expect(mockSessionHandler.stream.write).toHaveBeenCalled(); spySetOutputDir.mockRestore(); });