From 2ed4bba33ec1df5c958dc1c321a473919768a93c Mon Sep 17 00:00:00 2001 From: Delirium Date: Wed, 7 Jun 2023 10:40:32 +0200 Subject: [PATCH] feat(tracker/ui): support for multi tab sessions (#1236) * feat(tracker): add support for multi tab sessions * feat(backend): added support of multitabs * fix(backend): added support of deprecated batch meta message to pre-decoder * fix(backend): fixed nil meta issue for TabData messages in sink * feat(player): add tabmanager * feat(player): basic tabchange event support * feat(player): pick tabstate for console panel and timeline * fix(player): only display tabs that are created * feat(player): connect performance, xray and events to tab state * feat(player): merge all tabs data for overview * feat(backend/tracker): extract tabdata into separate message from batchmeta * fix(tracker): fix new session check * fix(backend): remove batchmetadeprecated * fix(backend): fix switch case * fix(player): fix for tab message size * feat(tracker): check for active tabs with broadcast channel * feat(tracker): prevent multiple messages * fix(tracker): ignore beacons from same tab, only ask if token isnt present yet, add small delay before start to wait for answer * feat(player): support new msg struct in assist player * fix(player): fix some livepl components for multi tab states * feat(tracker): add option to disable multitab * feat(tracker): add multitab to assist plugin * feat(player): back compat for tab id * fix(ui): fix missing list in controls * fix(ui): optional list update * feat(ui): fix visuals for multitab; use window focus event for tabs * fix(tracker): fix for dying tests (added tabid to writer, refactored other tests) * feat(ui): update LivePlayerSubHeader.tsx to support tabs * feat(backend): added tabs support to devtools mob files * feat(ui): connect state to current tab properly * feat(backend): added multitab support to assits * feat(backend): removed data check in agent message * feat(backend): debug on * fix(backend): fixed typo in message broadcast * feat(backend): fixed issue in connect method * fix(assist): fixed typo * feat(assist): added more debug logs * feat(assist): removed one log * feat(assist): more logs * feat(assist): use query.peerId * feat(assist): more logs * feat(assist): fixed session update * fix(assist): fixed getSessions * fix(assist): fixed request_control broadcast * fix(assist): fixed typo * fix(assist): added missed line * fix(assist): fix typo * feat(tracker): multitab support for assist sessions * fix(tracker): fix dead tests (tabid prop) * fix(tracker): fix yaml * fix(tracker): timers issue * fix(ui): fix ui E2E tests with magic? * feat(assist): multitabs support for ee version * fix(assist): added missed method import * fix(tracker): fix fix events in assist * feat(assist): added back compatibility for sessions without tabId * fix(assist): apply message's top layer structure before broadcast call * fix(assist): added random tabID for prev version * fix(assist): added random tabID for prev version (ee) * feat(assist): added debug logs * fix(assist): fix typo in sessions_agents_count method * fix(assist): fixed more typos in copy-pastes * fix(tracker): fix restart timings * feat(backend): added tabIDs for some events * feat(ui): add tab change event to the user steps bar * Revert "feat(backend): added tabIDs for some events" This reverts commit 1467ad7f9f817e98831259fe42ceb8967d5dc707. * feat(ui): revert timeline and xray to grab events from all tabs * fix(ui): fix typo --------- Co-authored-by: Alexander Zavorotynskiy --- .github/workflows/tracker-tests.yaml | 65 +++ .github/workflows/ui-tests.js.yml | 10 - assist/servers/websocket.js | 193 ++++--- assist/utils/helper.js | 25 +- backend/cmd/sink/main.go | 2 +- backend/pkg/messages/filters.go | 2 +- backend/pkg/messages/messages.go | 44 ++ backend/pkg/messages/read-message.go | 22 + ee/assist/servers/websocket-cluster.js | 192 ++++--- ee/assist/servers/websocket.js | 165 ++++-- ee/connectors/msgcodec/messages.py | 16 +- ee/connectors/msgcodec/msgcodec.py | 10 + .../Player/LivePlayer/LiveControls.tsx | 7 +- .../Player/LivePlayer/LivePlayerSubHeader.tsx | 50 +- .../Player/LivePlayer/Overlay/LiveOverlay.tsx | 5 +- .../Player/ReplayPlayer/EventsBlock/Event.js | 174 ------ .../EventsBlock/EventGroupWrapper.js | 130 ----- .../ReplayPlayer/EventsBlock/EventsBlock.tsx | 192 ------- .../ReplayPlayer/EventsBlock/LoadInfo.js | 40 -- .../ReplayPlayer/EventsBlock/NoteEvent.tsx | 127 ----- .../Player/ReplayPlayer/EventsBlock/index.js | 1 - .../Session/Player/SharedComponents/Tab.tsx | 29 + .../Session_/BugReport/BugReportModal.tsx | 6 +- .../Session_/EventsBlock/EventGroupWrapper.js | 21 +- .../Session_/EventsBlock/EventsBlock.tsx | 21 +- .../Session_/Exceptions/Exceptions.tsx | 3 +- .../components/Session_/GraphQL/GraphQL.tsx | 3 +- .../Session_/OverviewPanel/OverviewPanel.tsx | 24 +- .../Session_/Performance/Performance.tsx | 16 +- .../Session_/Player/Controls/Controls.tsx | 26 +- .../Session_/Player/Controls/Timeline.tsx | 6 +- .../Player/Controls/controls.module.css | 2 +- .../components/Session_/Player/Overlay.tsx | 3 +- .../components/Session_/Storage/Storage.tsx | 73 +-- frontend/app/components/Session_/Subheader.js | 276 +++++----- .../DevTools/ConsolePanel/ConsolePanel.tsx | 5 +- .../DevTools/NetworkPanel/NetworkPanel.tsx | 13 +- .../DevTools/ProfilerPanel/ProfilerPanel.tsx | 4 +- .../StackEventPanel/StackEventPanel.tsx | 7 +- frontend/app/player/common/ListWalker.ts | 28 +- frontend/app/player/web/MessageLoader.ts | 4 + frontend/app/player/web/MessageManager.ts | 507 +++++++----------- frontend/app/player/web/TabManager.ts | 327 +++++++++++ frontend/app/player/web/WebPlayer.ts | 9 +- .../app/player/web/assist/AssistManager.ts | 13 +- frontend/app/player/web/assist/Call.ts | 11 +- .../app/player/web/assist/RemoteControl.ts | 45 +- .../app/player/web/assist/ScreenRecording.ts | 16 +- .../player/web/managers/ActiveTabManager.ts | 18 + .../app/player/web/messages/MFileReader.ts | 7 + .../app/player/web/messages/MStreamReader.ts | 12 +- .../web/messages/RawMessageReader.gen.ts | 16 + .../app/player/web/messages/filters.gen.ts | 2 +- .../app/player/web/messages/message.gen.ts | 6 + frontend/app/player/web/messages/raw.gen.ts | 14 +- .../player/web/messages/tracker-legacy.gen.ts | 2 + .../app/player/web/messages/tracker.gen.ts | 26 +- frontend/app/player/web/storageSelectors.ts | 11 +- frontend/app/player/web/types/log.ts | 1 + frontend/app/types/session/event.ts | 3 + frontend/app/types/session/index.js | 2 +- frontend/app/types/session/session.ts | 2 +- frontend/cypress/e2e/replayer.cy.ts | 2 +- mobs/messages.rb | 8 + tracker/tracker-assist/.gitignore | 1 + tracker/tracker-assist/CHANGELOG.md | 4 + tracker/tracker-assist/jest.config.js | 13 + tracker/tracker-assist/package.json | 13 +- tracker/tracker-assist/src/Assist.ts | 100 +++- tracker/tracker-assist/src/RemoteControl.ts | 15 +- .../tests/AnnotationCanvas.test.ts | 148 +++++ .../tests/RemoteControl.test.ts | 208 +++++++ tracker/tracker-assist/tsconfig-cjs.json | 4 +- tracker/tracker-assist/tsconfig.json | 6 +- tracker/tracker/CHANGELOG.md | 4 + tracker/tracker/package.json | 7 +- tracker/tracker/src/common/interaction.ts | 1 + tracker/tracker/src/common/messages.gen.ts | 14 +- tracker/tracker/src/main/app/index.ts | 67 ++- tracker/tracker/src/main/app/messages.gen.ts | 18 + tracker/tracker/src/main/app/sanitizer.ts | 10 +- tracker/tracker/src/main/app/session.ts | 25 +- tracker/tracker/src/main/index.ts | 9 + tracker/tracker/src/main/modules/tabs.ts | 13 + tracker/tracker/src/main/utils.ts | 21 +- tracker/tracker/src/tests/guards.unit.test.ts | 113 ++++ .../tracker/src/tests/sanitizer.unit.test.ts | 135 +++++ tracker/tracker/src/tests/utils.unit.test.ts | 186 +++++++ tracker/tracker/src/webworker/BatchWriter.ts | 5 + .../src/webworker/BatchWriter.unit.test.ts | 9 +- .../src/webworker/MessageEncoder.gen.ts | 8 + tracker/tracker/src/webworker/QueueSender.ts | 2 +- .../src/webworker/QueueSender.unit.test.ts | 16 +- tracker/tracker/src/webworker/index.ts | 12 +- 94 files changed, 2749 insertions(+), 1540 deletions(-) create mode 100644 .github/workflows/tracker-tests.yaml delete mode 100644 frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/Event.js delete mode 100644 frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/EventGroupWrapper.js delete mode 100644 frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/EventsBlock.tsx delete mode 100644 frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/LoadInfo.js delete mode 100644 frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/NoteEvent.tsx delete mode 100644 frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/index.js create mode 100644 frontend/app/components/Session/Player/SharedComponents/Tab.tsx create mode 100644 frontend/app/player/web/TabManager.ts create mode 100644 frontend/app/player/web/managers/ActiveTabManager.ts create mode 100644 tracker/tracker-assist/jest.config.js create mode 100644 tracker/tracker-assist/tests/AnnotationCanvas.test.ts create mode 100644 tracker/tracker-assist/tests/RemoteControl.test.ts create mode 100644 tracker/tracker/src/main/modules/tabs.ts create mode 100644 tracker/tracker/src/tests/guards.unit.test.ts create mode 100644 tracker/tracker/src/tests/sanitizer.unit.test.ts create mode 100644 tracker/tracker/src/tests/utils.unit.test.ts diff --git a/.github/workflows/tracker-tests.yaml b/.github/workflows/tracker-tests.yaml new file mode 100644 index 000000000..bde61455a --- /dev/null +++ b/.github/workflows/tracker-tests.yaml @@ -0,0 +1,65 @@ +# Checking unit tests for tracker and assist +name: Tracker tests +on: + workflow_dispatch: + push: + branches: [ "main" ] + paths: + - tracker/** + pull_request: + branches: [ "dev", "main" ] + paths: + - frontend/** + - tracker/** +jobs: + build-and-test: + runs-on: macos-latest + name: Build and test Tracker + strategy: + matrix: + node-version: [ 16.x ] + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + - name: Cache tracker modules + uses: actions/cache@v3 + with: + path: tracker/tracker/node_modules + key: ${{ runner.OS }}-test_tracker_build-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + test_tracker_build{{ runner.OS }}-build- + test_tracker_build{{ runner.OS }}- + - name: Cache tracker-assist modules + uses: actions/cache@v3 + with: + path: tracker/tracker-assist/node_modules + key: ${{ runner.OS }}-test_tracker_build-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + test_tracker_build{{ runner.OS }}-build- + test_tracker_build{{ runner.OS }}- + - name: Setup Testing packages + run: | + cd tracker/tracker + npm i -g yarn + yarn + - name: Setup Testing packages + run: | + cd tracker/tracker-assist + yarn + - name: Jest tests + run: | + cd tracker/tracker + yarn test:ci + - name: Jest tests + run: | + cd tracker/tracker-assist + yarn test:ci + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: tracker + name: tracker \ No newline at end of file diff --git a/.github/workflows/ui-tests.js.yml b/.github/workflows/ui-tests.js.yml index c7b2f093f..94cdc183b 100644 --- a/.github/workflows/ui-tests.js.yml +++ b/.github/workflows/ui-tests.js.yml @@ -47,16 +47,6 @@ jobs: cd tracker/tracker npm i -g yarn yarn - - name: Jest tests - run: | - cd tracker/tracker - yarn test - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - flags: tracker - name: tracker - name: Build tracker inst run: | cd tracker/tracker diff --git a/assist/servers/websocket.js b/assist/servers/websocket.js index 44aed4d09..9fa11eed1 100644 --- a/assist/servers/websocket.js +++ b/assist/servers/websocket.js @@ -1,6 +1,7 @@ const _io = require('socket.io'); const express = require('express'); const { + extractRoomId, extractPeerId, extractProjectKeyFromRequest, extractSessionIdFromRequest, @@ -24,7 +25,7 @@ const { const wsRouter = express.Router(); let io; -const debug = process.env.debug === "1"; +const debug = true;//process.env.debug === "1"; const createSocketIOServer = function (server, prefix) { io = _io(server, { @@ -47,25 +48,30 @@ const respond = function (res, data) { const socketsList = async function (req, res) { debug && console.log("[WS]looking for all available sessions"); let filters = await extractPayloadFromRequest(req); - let liveSessions = {}; + let withFilters = hasFilters(filters); + let liveSessionsPerProject = {}; let rooms = await getAvailableRooms(io); - for (let peerId of rooms.keys()) { - let {projectKey, sessionId} = extractPeerId(peerId); + for (let roomId of rooms.keys()) { + let {projectKey, sessionId} = extractPeerId(roomId); if (projectKey !== undefined) { - liveSessions[projectKey] = liveSessions[projectKey] || []; - if (hasFilters(filters)) { - const connected_sockets = await io.in(peerId).fetchSockets(); + liveSessionsPerProject[projectKey] = liveSessionsPerProject[projectKey] || new Set(); + if (withFilters) { + const connected_sockets = await io.in(roomId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { - liveSessions[projectKey].push(sessionId); + liveSessionsPerProject[projectKey].add(sessionId); } } } else { - liveSessions[projectKey].push(sessionId); + liveSessionsPerProject[projectKey].add(sessionId); } } } + let liveSessions = {}; + liveSessionsPerProject.forEach((sessions, projectId) => { + liveSessions[projectId] = Array.from(sessions); + }); respond(res, liveSessions); } @@ -74,35 +80,36 @@ const socketsListByProject = async function (req, res) { let _projectKey = extractProjectKeyFromRequest(req); let _sessionId = extractSessionIdFromRequest(req); let filters = await extractPayloadFromRequest(req); - let liveSessions = {}; + let withFilters = hasFilters(filters); + let liveSessions = new Set(); let rooms = await getAvailableRooms(io); - for (let peerId of rooms.keys()) { - let {projectKey, sessionId} = extractPeerId(peerId); + for (let roomId of rooms.keys()) { + let {projectKey, sessionId} = extractPeerId(roomId); if (projectKey === _projectKey && (_sessionId === undefined || _sessionId === sessionId)) { - liveSessions[projectKey] = liveSessions[projectKey] || []; - if (hasFilters(filters)) { - const connected_sockets = await io.in(peerId).fetchSockets(); + if (withFilters) { + const connected_sockets = await io.in(roomId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { - liveSessions[projectKey].push(sessionId); + liveSessions.add(sessionId); } } } else { - liveSessions[projectKey].push(sessionId); + liveSessions.add(sessionId); } } } - liveSessions[_projectKey] = liveSessions[_projectKey] || []; - respond(res, _sessionId === undefined ? sortPaginate(liveSessions[_projectKey], filters) - : liveSessions[_projectKey].length > 0 ? liveSessions[_projectKey][0] + let sessions = Array.from(liveSessions); + respond(res, _sessionId === undefined ? sortPaginate(sessions, filters) + : sessions.length > 0 ? sessions[0] : null); } const socketsLive = async function (req, res) { debug && console.log("[WS]looking for all available LIVE sessions"); let filters = await extractPayloadFromRequest(req); - let liveSessions = {}; + let withFilters = hasFilters(filters); + let liveSessionsPerProject = {}; let rooms = await getAvailableRooms(io); for (let peerId of rooms.keys()) { let {projectKey} = extractPeerId(peerId); @@ -110,18 +117,22 @@ const socketsLive = async function (req, res) { let connected_sockets = await io.in(peerId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.session) { - liveSessions[projectKey] = liveSessions[projectKey] || []; - if (hasFilters(filters)) { + liveSessionsPerProject[projectKey] = liveSessionsPerProject[projectKey] || new Set(); + if (withFilters) { if (item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { - liveSessions[projectKey].push(item.handshake.query.sessionInfo); + liveSessionsPerProject[projectKey].add(item.handshake.query.sessionInfo); } } else { - liveSessions[projectKey].push(item.handshake.query.sessionInfo); + liveSessionsPerProject[projectKey].add(item.handshake.query.sessionInfo); } } } } } + let liveSessions = {}; + liveSessionsPerProject.forEach((sessions, projectId) => { + liveSessions[projectId] = Array.from(sessions); + }); respond(res, sortPaginate(liveSessions, filters)); } @@ -130,30 +141,36 @@ const socketsLiveByProject = async function (req, res) { let _projectKey = extractProjectKeyFromRequest(req); let _sessionId = extractSessionIdFromRequest(req); let filters = await extractPayloadFromRequest(req); - let liveSessions = {}; + let withFilters = hasFilters(filters); + let liveSessions = new Set(); + const sessIDs = new Set(); let rooms = await getAvailableRooms(io); - for (let peerId of rooms.keys()) { - let {projectKey, sessionId} = extractPeerId(peerId); + for (let roomId of rooms.keys()) { + let {projectKey, sessionId} = extractPeerId(roomId); if (projectKey === _projectKey && (_sessionId === undefined || _sessionId === sessionId)) { - let connected_sockets = await io.in(peerId).fetchSockets(); + let connected_sockets = await io.in(roomId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.session) { - liveSessions[projectKey] = liveSessions[projectKey] || []; - if (hasFilters(filters)) { - if (item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { - liveSessions[projectKey].push(item.handshake.query.sessionInfo); + if (withFilters) { + if (item.handshake.query.sessionInfo && + isValidSession(item.handshake.query.sessionInfo, filters.filter) && + !sessIDs.has(item.handshake.query.sessionInfo.sessionID) + ) { + liveSessions.add(item.handshake.query.sessionInfo); + sessIDs.add(item.handshake.query.sessionInfo.sessionID); } } else { - liveSessions[projectKey].push(item.handshake.query.sessionInfo); + if (!sessIDs.has(item.handshake.query.sessionInfo.sessionID)) { + liveSessions.add(item.handshake.query.sessionInfo); + sessIDs.add(item.handshake.query.sessionInfo.sessionID); + } } } } } } - liveSessions[_projectKey] = liveSessions[_projectKey] || []; - respond(res, _sessionId === undefined ? sortPaginate(liveSessions[_projectKey], filters) - : liveSessions[_projectKey].length > 0 ? liveSessions[_projectKey][0] - : null); + let sessions = Array.from(liveSessions); + respond(res, _sessionId === undefined ? sortPaginate(sessions, filters) : sessions.length > 0 ? sessions[0] : null); } const autocomplete = async function (req, res) { @@ -178,10 +195,10 @@ const autocomplete = async function (req, res) { respond(res, uniqueAutocomplete(results)); } -const findSessionSocketId = async (io, peerId) => { - const connected_sockets = await io.in(peerId).fetchSockets(); +const findSessionSocketId = async (io, roomId, tabId) => { + const connected_sockets = await io.in(roomId).fetchSockets(); for (let item of connected_sockets) { - if (item.handshake.query.identity === IDENTITIES.session) { + if (item.handshake.query.identity === IDENTITIES.session && item.tabId === tabId) { return item.id; } } @@ -191,8 +208,8 @@ const findSessionSocketId = async (io, peerId) => { async function sessions_agents_count(io, socket) { let c_sessions = 0, c_agents = 0; const rooms = await getAvailableRooms(io); - if (rooms.get(socket.peerId)) { - const connected_sockets = await io.in(socket.peerId).fetchSockets(); + if (rooms.get(socket.roomId)) { + const connected_sockets = await io.in(socket.roomId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.session) { @@ -211,8 +228,8 @@ async function sessions_agents_count(io, socket) { async function get_all_agents_ids(io, socket) { let agents = []; const rooms = await getAvailableRooms(io); - if (rooms.get(socket.peerId)) { - const connected_sockets = await io.in(socket.peerId).fetchSockets(); + if (rooms.get(socket.roomId)) { + const connected_sockets = await io.in(socket.roomId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.agent) { agents.push(item.id); @@ -245,56 +262,74 @@ module.exports = { socket.on(EVENTS_DEFINITION.listen.ERROR, err => errorHandler(EVENTS_DEFINITION.listen.ERROR, err)); debug && console.log(`WS started:${socket.id}, Query:${JSON.stringify(socket.handshake.query)}`); socket._connectedAt = new Date(); + + let {projectKey: connProjectKey, sessionId: connSessionId, tabId:connTabId} = extractPeerId(socket.handshake.query.peerId); socket.peerId = socket.handshake.query.peerId; + socket.roomId = extractRoomId(socket.peerId); + connTabId = connTabId ?? (Math.random() + 1).toString(36).substring(2); + socket.tabId = connTabId; socket.identity = socket.handshake.query.identity; + debug && console.log(`connProjectKey:${connProjectKey}, connSessionId:${connSessionId}, connTabId:${connTabId}, roomId:${socket.roomId}`); + let {c_sessions, c_agents} = await sessions_agents_count(io, socket); if (socket.identity === IDENTITIES.session) { if (c_sessions > 0) { - debug && console.log(`session already connected, refusing new connexion`); - io.to(socket.id).emit(EVENTS_DEFINITION.emit.SESSION_ALREADY_CONNECTED); - return socket.disconnect(); + const rooms = await getAvailableRooms(io); + for (let roomId of rooms.keys()) { + let {projectKey} = extractPeerId(roomId); + if (projectKey === connProjectKey) { + const connected_sockets = await io.in(roomId).fetchSockets(); + for (let item of connected_sockets) { + if (item.tabId === connTabId) { + debug && console.log(`session already connected, refusing new connexion`); + io.to(socket.id).emit(EVENTS_DEFINITION.emit.SESSION_ALREADY_CONNECTED); + return socket.disconnect(); + } + } + } + } } extractSessionInfo(socket); if (c_agents > 0) { debug && console.log(`notifying new session about agent-existence`); let agents_ids = await get_all_agents_ids(io, socket); io.to(socket.id).emit(EVENTS_DEFINITION.emit.AGENTS_CONNECTED, agents_ids); - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.SESSION_RECONNECTED, socket.id); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.SESSION_RECONNECTED, socket.id); } } else if (c_sessions <= 0) { debug && console.log(`notifying new agent about no SESSIONS with peerId:${socket.peerId}`); io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); } - await socket.join(socket.peerId); + await socket.join(socket.roomId); const rooms = await getAvailableRooms(io); - if (rooms.get(socket.peerId)) { - debug && console.log(`${socket.id} joined room:${socket.peerId}, as:${socket.identity}, members:${rooms.get(socket.peerId).size}`); + if (rooms.get(socket.roomId)) { + debug && console.log(`${socket.id} joined room:${socket.roomId}, as:${socket.identity}, members:${rooms.get(socket.roomId).size}`); } if (socket.identity === IDENTITIES.agent) { if (socket.handshake.query.agentInfo !== undefined) { socket.handshake.query.agentInfo = JSON.parse(socket.handshake.query.agentInfo); } - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NEW_AGENT, socket.id, socket.handshake.query.agentInfo); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.NEW_AGENT, socket.id, socket.handshake.query.agentInfo); } socket.on('disconnect', async () => { - debug && console.log(`${socket.id} disconnected from ${socket.peerId}`); + debug && console.log(`${socket.id} disconnected from ${socket.roomId}`); if (socket.identity === IDENTITIES.agent) { - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.AGENT_DISCONNECT, socket.id); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.AGENT_DISCONNECT, socket.id); } debug && console.log("checking for number of connected agents and sessions"); let {c_sessions, c_agents} = await sessions_agents_count(io, socket); if (c_sessions === -1 && c_agents === -1) { - debug && console.log(`room not found: ${socket.peerId}`); + debug && console.log(`room not found: ${socket.roomId}`); } if (c_sessions === 0) { - debug && console.log(`notifying everyone in ${socket.peerId} about no SESSIONS`); - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); + debug && console.log(`notifying everyone in ${socket.roomId} about no SESSIONS`); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); } if (c_agents === 0) { - debug && console.log(`notifying everyone in ${socket.peerId} about no AGENTS`); - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NO_AGENTS); + debug && console.log(`notifying everyone in ${socket.roomId} about no AGENTS`); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.NO_AGENTS); } }); @@ -304,8 +339,25 @@ module.exports = { debug && console.log('Ignoring update event.'); return } - socket.handshake.query.sessionInfo = {...socket.handshake.query.sessionInfo, ...args[0]}; - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.UPDATE_EVENT, args[0]); + // Back compatibility (add top layer with meta information) + if (args[0].meta === undefined) { + args[0] = {meta: {tabId: socket.tabId}, data: args[0]}; + } + Object.assign(socket.handshake.query.sessionInfo, args[0].data, {tabId: args[0].meta.tabId}); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.UPDATE_EVENT, args[0]); + // Update sessionInfo for all sessions (TODO: rewrite this) + const rooms = await getAvailableRooms(io); + for (let roomId of rooms.keys()) { + let {projectKey} = extractPeerId(roomId); + if (projectKey === connProjectKey) { + const connected_sockets = await io.in(roomId).fetchSockets(); + for (let item of connected_sockets) { + if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo) { + Object.assign(item.handshake.query.sessionInfo, args[0].data, {tabId: args[0].meta.tabId}); + } + } + } + } }); socket.on(EVENTS_DEFINITION.listen.CONNECT_ERROR, err => errorHandler(EVENTS_DEFINITION.listen.CONNECT_ERROR, err)); @@ -316,14 +368,19 @@ module.exports = { debug && console.log(`received event:${eventName}, should be handled by another listener, stopping onAny.`); return } + // Back compatibility (add top layer with meta information) + if (args[0].meta === undefined) { + args[0] = {meta: {tabId: socket.tabId}, data: args[0]}; + } if (socket.identity === IDENTITIES.session) { - debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.peerId}`); - socket.to(socket.peerId).emit(eventName, args[0]); + debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.roomId}`); + // TODO: emit message to all agents in the room (except tabs) + socket.to(socket.roomId).emit(eventName, args[0]); } else { - debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to session of room:${socket.peerId}`); - let socketId = await findSessionSocketId(io, socket.peerId); + debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to session of room:${socket.roomId}`); + let socketId = await findSessionSocketId(io, socket.roomId, args[0].meta.tabId); if (socketId === null) { - debug && console.log(`session not found for:${socket.peerId}`); + debug && console.log(`session not found for:${socket.roomId}`); io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); } else { debug && console.log("message sent"); @@ -342,7 +399,7 @@ module.exports = { const arr = Array.from(rooms); const filtered = arr.filter(room => !room[1].has(room[0])); for (let i of filtered) { - let {projectKey, sessionId} = extractPeerId(i[0]); + let {projectKey, sessionId, tabId} = extractPeerId(i[0]); if (projectKey !== null && sessionId !== null) { count++; } diff --git a/assist/utils/helper.js b/assist/utils/helper.js index 216518777..e842282a0 100644 --- a/assist/utils/helper.js +++ b/assist/utils/helper.js @@ -1,8 +1,22 @@ let PROJECT_KEY_LENGTH = parseInt(process.env.PROJECT_KEY_LENGTH) || 20; let debug = process.env.debug === "1" || false; +const extractRoomId = (peerId) => { + let {projectKey, sessionId, tabId} = extractPeerId(peerId); + if (projectKey && sessionId) { + return `${projectKey}-${sessionId}`; + } + return null; +} +const extractTabId = (peerId) => { + let {projectKey, sessionId, tabId} = extractPeerId(peerId); + if (tabId) { + return tabId; + } + return null; +} const extractPeerId = (peerId) => { let splited = peerId.split("-"); - if (splited.length !== 2) { + if (splited.length < 2 || splited.length > 3) { debug && console.error(`cannot split peerId: ${peerId}`); return {}; } @@ -10,7 +24,10 @@ const extractPeerId = (peerId) => { debug && console.error(`wrong project key length for peerId: ${peerId}`); return {}; } - return {projectKey: splited[0], sessionId: splited[1]}; + if (splited.length === 2) { + return {projectKey: splited[0], sessionId: splited[1], tabId: null}; + } + return {projectKey: splited[0], sessionId: splited[1], tabId: splited[2]}; }; const request_logger = (identity) => { return (req, res, next) => { @@ -185,7 +202,7 @@ const sortPaginate = function (list, filters) { list.sort((a, b) => { const tA = getValue(a, "timestamp"); const tB = getValue(b, "timestamp"); - return tA < tB ? 1 : tA > tB ? -1 : 0; + return tA < tB ? 1 : tA > tB ? -1 : 0; // b - a }); if (filters.sort.order) { list.reverse(); @@ -246,6 +263,8 @@ const getCompressionConfig = function () { } module.exports = { transformFilters, + extractRoomId, + extractTabId, extractPeerId, request_logger, getValidAttributes, diff --git a/backend/cmd/sink/main.go b/backend/cmd/sink/main.go index e9cf1367a..bb04c13cf 100644 --- a/backend/cmd/sink/main.go +++ b/backend/cmd/sink/main.go @@ -151,7 +151,7 @@ func main() { } // Add message to dev buffer - if !messages.IsDOMType(msg.TypeID()) || msg.TypeID() == messages.MsgTimestamp { + if !messages.IsDOMType(msg.TypeID()) || msg.TypeID() == messages.MsgTimestamp || msg.TypeID() == messages.MsgTabData { // Write message index n, err = devBuffer.Write(messageIndex) if err != nil { diff --git a/backend/pkg/messages/filters.go b/backend/pkg/messages/filters.go index f8997f418..540ebd06c 100644 --- a/backend/pkg/messages/filters.go +++ b/backend/pkg/messages/filters.go @@ -10,5 +10,5 @@ func IsIOSType(id int) bool { } func IsDOMType(id int) bool { - return 0 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 37 == id || 38 == id || 49 == id || 50 == id || 51 == id || 54 == id || 55 == id || 57 == id || 58 == id || 59 == id || 60 == id || 61 == id || 67 == id || 69 == id || 70 == id || 71 == id || 72 == id || 73 == id || 74 == id || 75 == id || 76 == id || 77 == id || 113 == id || 114 == id || 90 == id || 93 == id || 96 == id || 100 == id || 102 == id || 103 == id || 105 == id + return 0 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 37 == id || 38 == id || 49 == id || 50 == id || 51 == id || 54 == id || 55 == id || 57 == id || 58 == id || 59 == id || 60 == id || 61 == id || 67 == id || 69 == id || 70 == id || 71 == id || 72 == id || 73 == id || 74 == id || 75 == id || 76 == id || 77 == id || 113 == id || 114 == id || 117 == id || 118 == id || 90 == id || 93 == id || 96 == id || 100 == id || 102 == id || 103 == id || 105 == id } diff --git a/backend/pkg/messages/messages.go b/backend/pkg/messages/messages.go index 7a51c6ac9..2a4264fae 100644 --- a/backend/pkg/messages/messages.go +++ b/backend/pkg/messages/messages.go @@ -81,6 +81,8 @@ const ( MsgMouseThrashing = 114 MsgUnbindNodes = 115 MsgResourceTiming = 116 + MsgTabChange = 117 + MsgTabData = 118 MsgIssueEvent = 125 MsgSessionEnd = 126 MsgSessionSearch = 127 @@ -2163,6 +2165,48 @@ func (msg *ResourceTiming) TypeID() int { return 116 } +type TabChange struct { + message + TabId string +} + +func (msg *TabChange) Encode() []byte { + buf := make([]byte, 11+len(msg.TabId)) + buf[0] = 117 + p := 1 + p = WriteString(msg.TabId, buf, p) + return buf[:p] +} + +func (msg *TabChange) Decode() Message { + return msg +} + +func (msg *TabChange) TypeID() int { + return 117 +} + +type TabData struct { + message + TabId string +} + +func (msg *TabData) Encode() []byte { + buf := make([]byte, 11+len(msg.TabId)) + buf[0] = 118 + p := 1 + p = WriteString(msg.TabId, buf, p) + return buf[:p] +} + +func (msg *TabData) Decode() Message { + return msg +} + +func (msg *TabData) TypeID() int { + return 118 +} + type IssueEvent struct { message MessageID uint64 diff --git a/backend/pkg/messages/read-message.go b/backend/pkg/messages/read-message.go index a51200dc0..8c9aef886 100644 --- a/backend/pkg/messages/read-message.go +++ b/backend/pkg/messages/read-message.go @@ -1314,6 +1314,24 @@ func DecodeResourceTiming(reader BytesReader) (Message, error) { return msg, err } +func DecodeTabChange(reader BytesReader) (Message, error) { + var err error = nil + msg := &TabChange{} + if msg.TabId, err = reader.ReadString(); err != nil { + return nil, err + } + return msg, err +} + +func DecodeTabData(reader BytesReader) (Message, error) { + var err error = nil + msg := &TabData{} + if msg.TabId, err = reader.ReadString(); err != nil { + return nil, err + } + return msg, err +} + func DecodeIssueEvent(reader BytesReader) (Message, error) { var err error = nil msg := &IssueEvent{} @@ -1927,6 +1945,10 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) { return DecodeUnbindNodes(reader) case 116: return DecodeResourceTiming(reader) + case 117: + return DecodeTabChange(reader) + case 118: + return DecodeTabData(reader) case 125: return DecodeIssueEvent(reader) case 126: diff --git a/ee/assist/servers/websocket-cluster.js b/ee/assist/servers/websocket-cluster.js index 03e43b07a..4a92403d4 100644 --- a/ee/assist/servers/websocket-cluster.js +++ b/ee/assist/servers/websocket-cluster.js @@ -31,7 +31,7 @@ const pubClient = createClient({url: REDIS_URL}); const subClient = pubClient.duplicate(); console.log(`Using Redis: ${REDIS_URL}`); let io; -const debug = process.env.debug === "1"; +const debug = true;// = process.env.debug === "1"; const createSocketIOServer = function (server, prefix) { if (process.env.uws !== "true") { @@ -58,6 +58,7 @@ const createSocketIOServer = function (server, prefix) { } } +// TODO: Maybe we should use a Set instead of an array const uniqueSessions = function (data) { let resArr = []; let resArrIDS = []; @@ -85,25 +86,30 @@ const respond = function (res, data) { const socketsList = async function (req, res) { debug && console.log("[WS]looking for all available sessions"); let filters = await extractPayloadFromRequest(req, res); - let liveSessions = {}; + let withFilters = hasFilters(filters); + let liveSessionsPerProject = {}; let rooms = await getAvailableRooms(io); for (let peerId of rooms.keys()) { let {projectKey, sessionId} = extractPeerId(peerId); if (projectKey !== undefined) { - liveSessions[projectKey] = liveSessions[projectKey] || []; - if (hasFilters(filters)) { + liveSessionsPerProject[projectKey] = liveSessionsPerProject[projectKey] || new Set(); + if (withFilters) { const connected_sockets = await io.in(peerId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { - liveSessions[projectKey].push(sessionId); + liveSessionsPerProject[projectKey].add(sessionId); } } } else { - liveSessions[projectKey].push(sessionId); + liveSessionsPerProject[projectKey].add(sessionId); } } } + let liveSessions = {}; + liveSessionsPerProject.forEach((sessions, projectId) => { + liveSessions[projectId] = Array.from(sessions); + }); respond(res, liveSessions); } @@ -112,35 +118,37 @@ const socketsListByProject = async function (req, res) { let _projectKey = extractProjectKeyFromRequest(req); let _sessionId = extractSessionIdFromRequest(req); let filters = await extractPayloadFromRequest(req, res); - let liveSessions = {}; + let withFilters = hasFilters(filters); + let liveSessions = new Set(); let rooms = await getAvailableRooms(io); for (let peerId of rooms.keys()) { let {projectKey, sessionId} = extractPeerId(peerId); if (projectKey === _projectKey && (_sessionId === undefined || _sessionId === sessionId)) { - liveSessions[projectKey] = liveSessions[projectKey] || []; - if (hasFilters(filters)) { + if (withFilters) { const connected_sockets = await io.in(peerId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { - liveSessions[projectKey].push(sessionId); + liveSessions.add(sessionId); } } } else { - liveSessions[projectKey].push(sessionId); + liveSessions.add(sessionId); } } } - liveSessions[_projectKey] = liveSessions[_projectKey] || []; - respond(res, _sessionId === undefined ? sortPaginate(liveSessions[_projectKey], filters) - : liveSessions[_projectKey].length > 0 ? liveSessions[_projectKey][0] + let sessions = Array.from(liveSessions); + respond(res, _sessionId === undefined ? sortPaginate(sessions, filters) + : sessions.length > 0 ? sessions[0] : null); } const socketsLive = async function (req, res) { debug && console.log("[WS]looking for all available LIVE sessions"); let filters = await extractPayloadFromRequest(req, res); - let liveSessions = {}; + let withFilters = hasFilters(filters); + let liveSessionsPerProject = {}; + const sessIDs = new Set(); let rooms = await getAvailableRooms(io); for (let peerId of rooms.keys()) { let {projectKey} = extractPeerId(peerId); @@ -148,19 +156,31 @@ const socketsLive = async function (req, res) { let connected_sockets = await io.in(peerId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.session) { - liveSessions[projectKey] = liveSessions[projectKey] || []; - if (hasFilters(filters)) { - if (item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { - liveSessions[projectKey].push(item.handshake.query.sessionInfo); + liveSessionsPerProject[projectKey] = liveSessionsPerProject[projectKey] || new Set(); + if (withFilters) { + if (item.handshake.query.sessionInfo && + isValidSession(item.handshake.query.sessionInfo, filters.filter) && + !sessIDs.has(item.handshake.query.sessionInfo.sessionID) + ) { + liveSessionsPerProject[projectKey].add(item.handshake.query.sessionInfo); + sessIDs.add(item.handshake.query.sessionInfo.sessionID); } } else { - liveSessions[projectKey].push(item.handshake.query.sessionInfo); + if (!sessIDs.has(item.handshake.query.sessionInfo.sessionID)) { + liveSessionsPerProject[projectKey].add(item.handshake.query.sessionInfo); + sessIDs.add(item.handshake.query.sessionInfo.sessionID); + } } } } - liveSessions[projectKey] = uniqueSessions(liveSessions[projectKey]); + // Should be already unique + // liveSessionsPerProject[projectKey] = uniqueSessions(liveSessionsPerProject[projectKey]); } } + let liveSessions = {}; + liveSessionsPerProject.forEach((sessions, projectId) => { + liveSessions[projectId] = Array.from(sessions); + }); respond(res, sortPaginate(liveSessions, filters)); } @@ -169,7 +189,9 @@ const socketsLiveByProject = async function (req, res) { let _projectKey = extractProjectKeyFromRequest(req); let _sessionId = extractSessionIdFromRequest(req); let filters = await extractPayloadFromRequest(req, res); - let liveSessions = {}; + let withFilters = hasFilters(filters); + let liveSessions = new Set(); + const sessIDs = new Set(); let rooms = await getAvailableRooms(io); for (let peerId of rooms.keys()) { let {projectKey, sessionId} = extractPeerId(peerId); @@ -177,23 +199,28 @@ const socketsLiveByProject = async function (req, res) { let connected_sockets = await io.in(peerId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.session) { - liveSessions[projectKey] = liveSessions[projectKey] || []; - if (hasFilters(filters)) { - if (item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { - liveSessions[projectKey].push(item.handshake.query.sessionInfo); + if (withFilters) { + if (item.handshake.query.sessionInfo && + isValidSession(item.handshake.query.sessionInfo, filters.filter) && + !sessIDs.has(item.handshake.query.sessionInfo.sessionID) + ) { + liveSessions.add(item.handshake.query.sessionInfo); + sessIDs.add(item.handshake.query.sessionInfo.sessionID); } } else { - liveSessions[projectKey].push(item.handshake.query.sessionInfo); + if (!sessIDs.has(item.handshake.query.sessionInfo.sessionID)) { + liveSessions.add(item.handshake.query.sessionInfo); + sessIDs.add(item.handshake.query.sessionInfo.sessionID); + } } } } - liveSessions[projectKey] = uniqueSessions(liveSessions[projectKey] || []); + // Should be unique already because of using sessIDs set + // liveSessions[projectKey] = uniqueSessions(liveSessions[projectKey] || []); } } - liveSessions[_projectKey] = liveSessions[_projectKey] || []; - respond(res, _sessionId === undefined ? sortPaginate(liveSessions[_projectKey], filters) - : liveSessions[_projectKey].length > 0 ? liveSessions[_projectKey][0] - : null); + let sessions = Array.from(liveSessions); + respond(res, _sessionId === undefined ? sortPaginate(sessions, filters) : sessions.length > 0 ? sessions[0] : null); } const autocomplete = async function (req, res) { @@ -218,10 +245,10 @@ const autocomplete = async function (req, res) { respond(res, uniqueAutocomplete(results)); } -const findSessionSocketId = async (io, peerId) => { - const connected_sockets = await io.in(peerId).fetchSockets(); +const findSessionSocketId = async (io, roomId, tabId) => { + const connected_sockets = await io.in(roomId).fetchSockets(); for (let item of connected_sockets) { - if (item.handshake.query.identity === IDENTITIES.session) { + if (item.handshake.query.identity === IDENTITIES.session && item.tabId === tabId) { return item.id; } } @@ -231,8 +258,8 @@ const findSessionSocketId = async (io, peerId) => { async function sessions_agents_count(io, socket) { let c_sessions = 0, c_agents = 0; const rooms = await getAvailableRooms(io); - if (rooms.has(socket.peerId)) { - const connected_sockets = await io.in(socket.peerId).fetchSockets(); + if (rooms.has(socket.roomId)) { + const connected_sockets = await io.in(socket.roomId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.session) { @@ -251,8 +278,8 @@ async function sessions_agents_count(io, socket) { async function get_all_agents_ids(io, socket) { let agents = []; const rooms = await getAvailableRooms(io); - if (rooms.has(socket.peerId)) { - const connected_sockets = await io.in(socket.peerId).fetchSockets(); + if (rooms.has(socket.roomId)) { + const connected_sockets = await io.in(socket.roomId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.agent) { agents.push(item.id); @@ -285,57 +312,76 @@ module.exports = { socket.on(EVENTS_DEFINITION.listen.ERROR, err => errorHandler(EVENTS_DEFINITION.listen.ERROR, err)); debug && console.log(`WS started:${socket.id}, Query:${JSON.stringify(socket.handshake.query)}`); socket._connectedAt = new Date(); + + let {projectKey: connProjectKey, sessionId: connSessionId, tabId:connTabId} = extractPeerId(socket.handshake.query.peerId); socket.peerId = socket.handshake.query.peerId; + socket.roomId = extractRoomId(socket.peerId); + // Set default tabId for back compatibility + connTabId = connTabId ?? (Math.random() + 1).toString(36).substring(2); + socket.tabId = connTabId; socket.identity = socket.handshake.query.identity; + debug && console.log(`connProjectKey:${connProjectKey}, connSessionId:${connSessionId}, connTabId:${connTabId}, roomId:${socket.roomId}`); + let {c_sessions, c_agents} = await sessions_agents_count(io, socket); if (socket.identity === IDENTITIES.session) { if (c_sessions > 0) { - debug && console.log(`session already connected, refusing new connexion`); - io.to(socket.id).emit(EVENTS_DEFINITION.emit.SESSION_ALREADY_CONNECTED); - return socket.disconnect(); + const rooms = await getAvailableRooms(io); + for (let roomId of rooms.keys()) { + let {projectKey} = extractPeerId(roomId); + if (projectKey === connProjectKey) { + const connected_sockets = await io.in(roomId).fetchSockets(); + for (let item of connected_sockets) { + if (item.tabId === connTabId) { + debug && console.log(`session already connected, refusing new connexion`); + io.to(socket.id).emit(EVENTS_DEFINITION.emit.SESSION_ALREADY_CONNECTED); + return socket.disconnect(); + } + } + } + } } extractSessionInfo(socket); if (c_agents > 0) { debug && console.log(`notifying new session about agent-existence`); let agents_ids = await get_all_agents_ids(io, socket); io.to(socket.id).emit(EVENTS_DEFINITION.emit.AGENTS_CONNECTED, agents_ids); - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.SESSION_RECONNECTED, socket.id); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.SESSION_RECONNECTED, socket.id); } } else if (c_sessions <= 0) { debug && console.log(`notifying new agent about no SESSIONS with peerId:${socket.peerId}`); io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); } - await socket.join(socket.peerId); + await socket.join(socket.roomId); const rooms = await getAvailableRooms(io); - if (rooms.has(socket.peerId)) { - let connectedSockets = await io.in(socket.peerId).fetchSockets(); - debug && console.log(`${socket.id} joined room:${socket.peerId}, as:${socket.identity}, members:${connectedSockets.length}`); + if (rooms.has(socket.roomId)) { + let connectedSockets = await io.in(socket.roomId).fetchSockets(); + debug && console.log(`${socket.id} joined room:${socket.roomId}, as:${socket.identity}, members:${connectedSockets.length}`); } if (socket.identity === IDENTITIES.agent) { if (socket.handshake.query.agentInfo !== undefined) { socket.handshake.query.agentInfo = JSON.parse(socket.handshake.query.agentInfo); } - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NEW_AGENT, socket.id, socket.handshake.query.agentInfo); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.NEW_AGENT, socket.id, socket.handshake.query.agentInfo); } socket.on('disconnect', async () => { - debug && console.log(`${socket.id} disconnected from ${socket.peerId}`); + debug && console.log(`${socket.id} disconnected from ${socket.roomId}`); if (socket.identity === IDENTITIES.agent) { - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.AGENT_DISCONNECT, socket.id); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.AGENT_DISCONNECT, socket.id); } debug && console.log("checking for number of connected agents and sessions"); let {c_sessions, c_agents} = await sessions_agents_count(io, socket); if (c_sessions === -1 && c_agents === -1) { - debug && console.log(`room not found: ${socket.peerId}`); + debug && console.log(`room not found: ${socket.roomId}`); } if (c_sessions === 0) { - debug && console.log(`notifying everyone in ${socket.peerId} about no SESSIONS`); - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); + debug && console.log(`notifying everyone in ${socket.roomId} about no SESSIONS`); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); } if (c_agents === 0) { - debug && console.log(`notifying everyone in ${socket.peerId} about no AGENTS`); - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NO_AGENTS); + debug && console.log(`notifying everyone in ${socket.roomId} about no AGENTS`); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.NO_AGENTS); } }); @@ -345,8 +391,25 @@ module.exports = { debug && console.log('Ignoring update event.'); return } - socket.handshake.query.sessionInfo = {...socket.handshake.query.sessionInfo, ...args[0]}; - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.UPDATE_EVENT, args[0]); + // Back compatibility (add top layer with meta information) + if (args[0].meta === undefined) { + args[0] = {meta: {tabId: socket.tabId}, data: args[0]}; + } + Object.assign(socket.handshake.query.sessionInfo, args[0].data, {tabId: args[0].meta.tabId}); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.UPDATE_EVENT, args[0]); + // Update sessionInfo for all sessions (TODO: rewrite this) + const rooms = await getAvailableRooms(io); + for (let roomId of rooms.keys()) { + let {projectKey} = extractPeerId(roomId); + if (projectKey === connProjectKey) { + const connected_sockets = await io.in(roomId).fetchSockets(); + for (let item of connected_sockets) { + if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo) { + Object.assign(item.handshake.query.sessionInfo, args[0].data, {tabId: args[0].meta.tabId}); + } + } + } + } }); socket.on(EVENTS_DEFINITION.listen.CONNECT_ERROR, err => errorHandler(EVENTS_DEFINITION.listen.CONNECT_ERROR, err)); @@ -357,14 +420,19 @@ module.exports = { debug && console.log(`received event:${eventName}, should be handled by another listener, stopping onAny.`); return } + // Back compatibility (add top layer with meta information) + if (args[0].meta === undefined) { + args[0] = {meta: {tabId: socket.tabId}, data: args[0]}; + } if (socket.identity === IDENTITIES.session) { - debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.peerId}`); - socket.to(socket.peerId).emit(eventName, args[0]); + debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.roomId}`); + // TODO: send to all agents in the room + socket.to(socket.roomId).emit(eventName, args[0]); } else { - debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to session of room:${socket.peerId}`); - let socketId = await findSessionSocketId(io, socket.peerId); + debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to session of room:${socket.roomId}`); + let socketId = await findSessionSocketId(io, socket.roomId, args[0].meta.tabId); if (socketId === null) { - debug && console.log(`session not found for:${socket.peerId}`); + debug && console.log(`session not found for:${socket.roomId}`); io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); } else { debug && console.log("message sent"); diff --git a/ee/assist/servers/websocket.js b/ee/assist/servers/websocket.js index c1ff7cbb5..0cc897318 100644 --- a/ee/assist/servers/websocket.js +++ b/ee/assist/servers/websocket.js @@ -1,6 +1,7 @@ const _io = require('socket.io'); const express = require('express'); const { + extractRoomId, extractPeerId, hasFilters, isValidSession, @@ -26,7 +27,7 @@ const { const wsRouter = express.Router(); let io; -const debug = process.env.debug === "1"; +const debug = true;//process.env.debug === "1"; const createSocketIOServer = function (server, prefix) { if (process.env.uws !== "true") { @@ -67,25 +68,30 @@ const respond = function (res, data) { const socketsList = async function (req, res) { debug && console.log("[WS]looking for all available sessions"); let filters = await extractPayloadFromRequest(req, res); - let liveSessions = {}; + let withFilters = hasFilters(filters); + let liveSessionsPerProject = {}; let rooms = await getAvailableRooms(io); for (let peerId of rooms.keys()) { let {projectKey, sessionId} = extractPeerId(peerId); if (projectKey !== undefined) { - liveSessions[projectKey] = liveSessions[projectKey] || []; - if (hasFilters(filters)) { + liveSessionsPerProject[projectKey] = liveSessionsPerProject[projectKey] || new Set(); + if (withFilters) { const connected_sockets = await io.in(peerId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { - liveSessions[projectKey].push(sessionId); + liveSessionsPerProject[projectKey].add(sessionId); } } } else { - liveSessions[projectKey].push(sessionId); + liveSessionsPerProject[projectKey].add(sessionId); } } } + let liveSessions = {}; + liveSessionsPerProject.forEach((sessions, projectId) => { + liveSessions[projectId] = Array.from(sessions); + }); respond(res, liveSessions); } @@ -94,35 +100,36 @@ const socketsListByProject = async function (req, res) { let _projectKey = extractProjectKeyFromRequest(req); let _sessionId = extractSessionIdFromRequest(req); let filters = await extractPayloadFromRequest(req, res); - let liveSessions = {}; + let withFilters = hasFilters(filters); + let liveSessions = new Set(); let rooms = await getAvailableRooms(io); for (let peerId of rooms.keys()) { let {projectKey, sessionId} = extractPeerId(peerId); if (projectKey === _projectKey && (_sessionId === undefined || _sessionId === sessionId)) { - liveSessions[projectKey] = liveSessions[projectKey] || []; - if (hasFilters(filters)) { + if (withFilters) { const connected_sockets = await io.in(peerId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { - liveSessions[projectKey].push(sessionId); + liveSessions.add(sessionId); } } } else { - liveSessions[projectKey].push(sessionId); + liveSessions.add(sessionId); } } } - liveSessions[_projectKey] = liveSessions[_projectKey] || []; - respond(res, _sessionId === undefined ? sortPaginate(liveSessions[_projectKey], filters) - : liveSessions[_projectKey].length > 0 ? liveSessions[_projectKey][0] + let sessions = Array.from(liveSessions); + respond(res, _sessionId === undefined ? sortPaginate(sessions, filters) + : sessions.length > 0 ? sessions[0] : null); } const socketsLive = async function (req, res) { debug && console.log("[WS]looking for all available LIVE sessions"); let filters = await extractPayloadFromRequest(req, res); - let liveSessions = {}; + let withFilters = hasFilters(filters); + let liveSessionsPerProject = {}; let rooms = await getAvailableRooms(io); for (let peerId of rooms.keys()) { let {projectKey} = extractPeerId(peerId); @@ -130,18 +137,22 @@ const socketsLive = async function (req, res) { let connected_sockets = await io.in(peerId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.session) { - liveSessions[projectKey] = liveSessions[projectKey] || []; + liveSessionsPerProject[projectKey] = liveSessionsPerProject[projectKey] || new Set(); if (hasFilters(filters)) { if (item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { - liveSessions[projectKey].push(item.handshake.query.sessionInfo); + liveSessionsPerProject[projectKey].add(item.handshake.query.sessionInfo); } } else { - liveSessions[projectKey].push(item.handshake.query.sessionInfo); + liveSessionsPerProject[projectKey].add(item.handshake.query.sessionInfo); } } } } } + let liveSessions = {}; + liveSessionsPerProject.forEach((sessions, projectId) => { + liveSessions[projectId] = Array.from(sessions); + }); respond(res, sortPaginate(liveSessions, filters)); } @@ -150,7 +161,9 @@ const socketsLiveByProject = async function (req, res) { let _projectKey = extractProjectKeyFromRequest(req); let _sessionId = extractSessionIdFromRequest(req); let filters = await extractPayloadFromRequest(req, res); - let liveSessions = {}; + let withFilters = hasFilters(filters); + let liveSessions = new Set(); + const sessIDs = new Set(); let rooms = await getAvailableRooms(io); for (let peerId of rooms.keys()) { let {projectKey, sessionId} = extractPeerId(peerId); @@ -158,22 +171,26 @@ const socketsLiveByProject = async function (req, res) { let connected_sockets = await io.in(peerId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.session) { - liveSessions[projectKey] = liveSessions[projectKey] || []; - if (hasFilters(filters)) { - if (item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { - liveSessions[projectKey].push(item.handshake.query.sessionInfo); + if (withFilters) { + if (item.handshake.query.sessionInfo && + isValidSession(item.handshake.query.sessionInfo, filters.filter) && + !sessIDs.has(item.handshake.query.sessionInfo.sessionID) + ) { + liveSessions.add(item.handshake.query.sessionInfo); + sessIDs.add(item.handshake.query.sessionInfo.sessionID); } } else { - liveSessions[projectKey].push(item.handshake.query.sessionInfo); + if (!sessIDs.has(item.handshake.query.sessionInfo.sessionID)) { + liveSessions.add(item.handshake.query.sessionInfo); + sessIDs.add(item.handshake.query.sessionInfo.sessionID); + } } } } } } - liveSessions[_projectKey] = liveSessions[_projectKey] || []; - respond(res, _sessionId === undefined ? sortPaginate(liveSessions[_projectKey], filters) - : liveSessions[_projectKey].length > 0 ? liveSessions[_projectKey][0] - : null); + let sessions = Array.from(liveSessions); + respond(res, _sessionId === undefined ? sortPaginate(sessions, filters) : sessions.length > 0 ? sessions[0] : null); } const autocomplete = async function (req, res) { @@ -198,10 +215,10 @@ const autocomplete = async function (req, res) { respond(res, uniqueAutocomplete(results)); } -const findSessionSocketId = async (io, peerId) => { - const connected_sockets = await io.in(peerId).fetchSockets(); +const findSessionSocketId = async (io, roomId, tabId) => { + const connected_sockets = await io.in(roomId).fetchSockets(); for (let item of connected_sockets) { - if (item.handshake.query.identity === IDENTITIES.session) { + if (item.handshake.query.identity === IDENTITIES.session && item.tabId === tabId) { return item.id; } } @@ -211,8 +228,8 @@ const findSessionSocketId = async (io, peerId) => { async function sessions_agents_count(io, socket) { let c_sessions = 0, c_agents = 0; const rooms = await getAvailableRooms(io); - if (rooms.get(socket.peerId)) { - const connected_sockets = await io.in(socket.peerId).fetchSockets(); + if (rooms.get(socket.roomId)) { + const connected_sockets = await io.in(socket.roomId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.session) { @@ -231,8 +248,8 @@ async function sessions_agents_count(io, socket) { async function get_all_agents_ids(io, socket) { let agents = []; const rooms = await getAvailableRooms(io); - if (rooms.get(socket.peerId)) { - const connected_sockets = await io.in(socket.peerId).fetchSockets(); + if (rooms.get(socket.roomId)) { + const connected_sockets = await io.in(socket.roomId).fetchSockets(); for (let item of connected_sockets) { if (item.handshake.query.identity === IDENTITIES.agent) { agents.push(item.id); @@ -265,56 +282,75 @@ module.exports = { socket.on(EVENTS_DEFINITION.listen.ERROR, err => errorHandler(EVENTS_DEFINITION.listen.ERROR, err)); debug && console.log(`WS started:${socket.id}, Query:${JSON.stringify(socket.handshake.query)}`); socket._connectedAt = new Date(); + + let {projectKey: connProjectKey, sessionId: connSessionId, tabId:connTabId} = extractPeerId(socket.handshake.query.peerId); socket.peerId = socket.handshake.query.peerId; + socket.roomId = extractRoomId(socket.peerId); + // Set default tabId for back compatibility + connTabId = connTabId ?? (Math.random() + 1).toString(36).substring(2); + socket.tabId = connTabId; socket.identity = socket.handshake.query.identity; + debug && console.log(`connProjectKey:${connProjectKey}, connSessionId:${connSessionId}, connTabId:${connTabId}, roomId:${socket.roomId}`); + let {c_sessions, c_agents} = await sessions_agents_count(io, socket); if (socket.identity === IDENTITIES.session) { if (c_sessions > 0) { - debug && console.log(`session already connected, refusing new connexion`); - io.to(socket.id).emit(EVENTS_DEFINITION.emit.SESSION_ALREADY_CONNECTED); - return socket.disconnect(); + const rooms = await getAvailableRooms(io); + for (let roomId of rooms.keys()) { + let {projectKey} = extractPeerId(roomId); + if (projectKey === connProjectKey) { + const connected_sockets = await io.in(roomId).fetchSockets(); + for (let item of connected_sockets) { + if (item.tabId === connTabId) { + debug && console.log(`session already connected, refusing new connexion`); + io.to(socket.id).emit(EVENTS_DEFINITION.emit.SESSION_ALREADY_CONNECTED); + return socket.disconnect(); + } + } + } + } } extractSessionInfo(socket); if (c_agents > 0) { debug && console.log(`notifying new session about agent-existence`); let agents_ids = await get_all_agents_ids(io, socket); io.to(socket.id).emit(EVENTS_DEFINITION.emit.AGENTS_CONNECTED, agents_ids); - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.SESSION_RECONNECTED, socket.id); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.SESSION_RECONNECTED, socket.id); } } else if (c_sessions <= 0) { debug && console.log(`notifying new agent about no SESSIONS with peerId:${socket.peerId}`); io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); } - await socket.join(socket.peerId); + await socket.join(socket.roomId); const rooms = await getAvailableRooms(io); - if (rooms.get(socket.peerId)) { - debug && console.log(`${socket.id} joined room:${socket.peerId}, as:${socket.identity}, members:${rooms.get(socket.peerId).size}`); + if (rooms.get(socket.roomId)) { + debug && console.log(`${socket.id} joined room:${socket.roomId}, as:${socket.identity}, members:${rooms.get(socket.roomId).size}`); } if (socket.identity === IDENTITIES.agent) { if (socket.handshake.query.agentInfo !== undefined) { socket.handshake.query.agentInfo = JSON.parse(socket.handshake.query.agentInfo); } - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NEW_AGENT, socket.id, socket.handshake.query.agentInfo); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.NEW_AGENT, socket.id, socket.handshake.query.agentInfo); } socket.on('disconnect', async () => { - debug && console.log(`${socket.id} disconnected from ${socket.peerId}`); + debug && console.log(`${socket.id} disconnected from ${socket.roomId}`); if (socket.identity === IDENTITIES.agent) { - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.AGENT_DISCONNECT, socket.id); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.AGENT_DISCONNECT, socket.id); } debug && console.log("checking for number of connected agents and sessions"); let {c_sessions, c_agents} = await sessions_agents_count(io, socket); if (c_sessions === -1 && c_agents === -1) { - debug && console.log(`room not found: ${socket.peerId}`); + debug && console.log(`room not found: ${socket.roomId}`); } if (c_sessions === 0) { - debug && console.log(`notifying everyone in ${socket.peerId} about no SESSIONS`); - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); + debug && console.log(`notifying everyone in ${socket.roomId} about no SESSIONS`); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); } if (c_agents === 0) { debug && console.log(`notifying everyone in ${socket.peerId} about no AGENTS`); - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NO_AGENTS); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.NO_AGENTS); } }); @@ -324,8 +360,25 @@ module.exports = { debug && console.log('Ignoring update event.'); return } - socket.handshake.query.sessionInfo = {...socket.handshake.query.sessionInfo, ...args[0]}; - socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.UPDATE_EVENT, args[0]); + // Back compatibility (add top layer with meta information) + if (args[0].meta === undefined) { + args[0] = {meta: {tabId: socket.tabId}, data: args[0]}; + } + Object.assign(socket.handshake.query.sessionInfo, args[0].data, {tabId: args[0].meta.tabId}); + socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.UPDATE_EVENT, args[0]); + // Update sessionInfo for all sessions (TODO: rewrite this) + const rooms = await getAvailableRooms(io); + for (let roomId of rooms.keys()) { + let {projectKey} = extractPeerId(roomId); + if (projectKey === connProjectKey) { + const connected_sockets = await io.in(roomId).fetchSockets(); + for (let item of connected_sockets) { + if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo) { + Object.assign(item.handshake.query.sessionInfo, args[0].data, {tabId: args[0].meta.tabId}); + } + } + } + } }); socket.on(EVENTS_DEFINITION.listen.CONNECT_ERROR, err => errorHandler(EVENTS_DEFINITION.listen.CONNECT_ERROR, err)); @@ -336,14 +389,18 @@ module.exports = { debug && console.log(`received event:${eventName}, should be handled by another listener, stopping onAny.`); return } + // Back compatibility (add top layer with meta information) + if (args[0].meta === undefined) { + args[0] = {meta: {tabId: socket.tabId}, data: args[0]}; + } if (socket.identity === IDENTITIES.session) { debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.peerId}`); - socket.to(socket.peerId).emit(eventName, args[0]); + socket.to(socket.roomId).emit(eventName, args[0]); } else { debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to session of room:${socket.peerId}`); - let socketId = await findSessionSocketId(io, socket.peerId); + let socketId = await findSessionSocketId(io, socket.roomId, args[0].meta.tabId); if (socketId === null) { - debug && console.log(`session not found for:${socket.peerId}`); + debug && console.log(`session not found for:${socket.roomId}`); io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); } else { debug && console.log("message sent"); diff --git a/ee/connectors/msgcodec/messages.py b/ee/connectors/msgcodec/messages.py index 7e2f28152..514d2241c 100644 --- a/ee/connectors/msgcodec/messages.py +++ b/ee/connectors/msgcodec/messages.py @@ -71,7 +71,7 @@ class CreateDocument(Message): __id__ = 7 def __init__(self, ): - pass + class CreateElementNode(Message): @@ -759,6 +759,20 @@ class ResourceTiming(Message): self.cached = cached +class TabChange(Message): + __id__ = 117 + + def __init__(self, tab_id): + self.tab_id = tab_id + + +class TabData(Message): + __id__ = 118 + + def __init__(self, tab_id): + self.tab_id = tab_id + + class IssueEvent(Message): __id__ = 125 diff --git a/ee/connectors/msgcodec/msgcodec.py b/ee/connectors/msgcodec/msgcodec.py index 64d094521..8da9bd7f5 100644 --- a/ee/connectors/msgcodec/msgcodec.py +++ b/ee/connectors/msgcodec/msgcodec.py @@ -689,6 +689,16 @@ class MessageCodec(Codec): cached=self.read_boolean(reader) ) + if message_id == 117: + return TabChange( + tab_id=self.read_string(reader) + ) + + if message_id == 118: + return TabData( + tab_id=self.read_string(reader) + ) + if message_id == 125: return IssueEvent( message_id=self.read_uint(reader), diff --git a/frontend/app/components/Session/Player/LivePlayer/LiveControls.tsx b/frontend/app/components/Session/Player/LivePlayer/LiveControls.tsx index 7a5ccb8d3..fa32bba1c 100644 --- a/frontend/app/components/Session/Player/LivePlayer/LiveControls.tsx +++ b/frontend/app/components/Session/Player/LivePlayer/LiveControls.tsx @@ -24,9 +24,12 @@ function Controls(props: any) { const { jumpToLive } = player; const { livePlay, - logMarkedCountNow: logRedCount, - exceptionsList, + currentTab, + tabStates } = store.get(); + + const exceptionsList = tabStates[currentTab]?.exceptionsList || []; + const logRedCount = tabStates[currentTab]?.logMarkedCountNow || 0; const showExceptions = exceptionsList.length > 0; const { bottomBlock, diff --git a/frontend/app/components/Session/Player/LivePlayer/LivePlayerSubHeader.tsx b/frontend/app/components/Session/Player/LivePlayer/LivePlayerSubHeader.tsx index 94892a3a7..6fa882fc5 100644 --- a/frontend/app/components/Session/Player/LivePlayer/LivePlayerSubHeader.tsx +++ b/frontend/app/components/Session/Player/LivePlayer/LivePlayerSubHeader.tsx @@ -1,39 +1,43 @@ import React from 'react'; import { Icon, Tooltip } from 'UI'; -import copy from 'copy-to-clipboard'; import { PlayerContext } from 'App/components/Session/playerContext'; import { observer } from 'mobx-react-lite'; +import Tab from 'Components/Session/Player/SharedComponents/Tab'; function SubHeader() { - const { store } = React.useContext(PlayerContext) - const { - location: currentLocation, - } = store.get() - const [isCopied, setCopied] = React.useState(false); + const { store } = React.useContext(PlayerContext); + const { tabStates, currentTab, tabs } = store.get(); + const currentLocation = tabStates[currentTab]?.location || ''; const location = - currentLocation !== undefined ? currentLocation.length > 60 - ? `${currentLocation.slice(0, 60)}...` - : currentLocation : undefined; + currentLocation !== undefined + ? currentLocation.length > 70 + ? `${currentLocation.slice(0, 70)}...` + : currentLocation + : undefined; return ( -
+ <> +
+ {tabs.map((tab, i) => ( + + + + ))} +
{location && ( -
{ - copy(currentLocation || ''); - setCopied(true); - setTimeout(() => setCopied(false), 5000); - }} - > - - - {location} - +
+
)} -
+ ); } diff --git a/frontend/app/components/Session/Player/LivePlayer/Overlay/LiveOverlay.tsx b/frontend/app/components/Session/Player/LivePlayer/Overlay/LiveOverlay.tsx index 38eef2ba1..431fc617f 100644 --- a/frontend/app/components/Session/Player/LivePlayer/Overlay/LiveOverlay.tsx +++ b/frontend/app/components/Session/Player/LivePlayer/Overlay/LiveOverlay.tsx @@ -25,13 +25,16 @@ function Overlay({ const { messagesLoading, - cssLoading, peerConnectionStatus, livePlay, calling, remoteControl, recordingState, + tabStates, + currentTab } = store.get() + + const cssLoading = tabStates[currentTab]?.cssLoading || false const loading = messagesLoading || cssLoading const liveStatusText = getStatusText(peerConnectionStatus) const connectionStatus = peerConnectionStatus diff --git a/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/Event.js b/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/Event.js deleted file mode 100644 index e8f985aa0..000000000 --- a/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/Event.js +++ /dev/null @@ -1,174 +0,0 @@ -import React from 'react'; -import copy from 'copy-to-clipboard'; -import cn from 'classnames'; -import { Icon, TextEllipsis } from 'UI'; -import { TYPES } from 'Types/session/event'; -import { prorata } from 'App/utils'; -import withOverlay from 'Components/hocs/withOverlay'; -import LoadInfo from './LoadInfo'; -import cls from './event.module.css'; -import { numberWithCommas } from 'App/utils'; - -@withOverlay() -export default class Event extends React.PureComponent { - state = { - menuOpen: false, - } - - componentDidMount() { - this.wrapper.addEventListener('contextmenu', this.onContextMenu); - } - - onContextMenu = (e) => { - e.preventDefault(); - this.setState({ menuOpen: true }); - } - onMouseLeave = () => this.setState({ menuOpen: false }) - - copyHandler = (e) => { - e.stopPropagation(); - //const ctrlOrCommandPressed = e.ctrlKey || e.metaKey; - //if (ctrlOrCommandPressed && e.keyCode === 67) { - const { event } = this.props; - copy(event.getIn([ 'target', 'path' ]) || event.url || ''); - this.setState({ menuOpen: false }); - } - - toggleInfo = (e) => { - e.stopPropagation(); - this.props.toggleInfo(); - } - - // eslint-disable-next-line complexity - renderBody = () => { - const { event } = this.props; - let title = event.type; - let body; - switch (event.type) { - case TYPES.LOCATION: - title = 'Visited'; - body = event.url; - break; - case TYPES.CLICK: - title = 'Clicked'; - body = event.label; - break; - case TYPES.INPUT: - title = 'Input'; - body = event.value; - break; - case TYPES.CLICKRAGE: - title = `${ event.count } Clicks`; - body = event.label; - break; - case TYPES.IOS_VIEW: - title = 'View'; - body = event.name; - break; - } - const isLocation = event.type === TYPES.LOCATION; - const isClickrage = event.type === TYPES.CLICKRAGE; - - return ( -
-
- { event.type && } -
-
-
- { title } - {/* { body && !isLocation &&
{ body }
} */} - { body && !isLocation && - - } -
- { isLocation && event.speedIndex != null && -
-
{"Speed Index"}
-
{ numberWithCommas(event.speedIndex || 0) }
-
- } -
- { event.target && event.target.label && -
{ event.target.label }
- } -
-
- { isLocation && -
- { body } -
- } -
- ); - }; - - render() { - const { - event, - selected, - isCurrent, - onClick, - showSelection, - onCheckboxClick, - showLoadInfo, - toggleLoadInfo, - isRed, - extended, - highlight = false, - presentInSearch = false, - isLastInGroup, - whiteBg, - } = this.props; - const { menuOpen } = this.state; - return ( -
{ this.wrapper = ref } } - onMouseLeave={ this.onMouseLeave } - data-openreplay-label="Event" - data-type={event.type} - className={ cn(cls.event, { - [ cls.menuClosed ]: !menuOpen, - [ cls.highlighted ]: showSelection ? selected : isCurrent, - [ cls.selected ]: selected, - [ cls.showSelection ]: showSelection, - [ cls.red ]: isRed, - [ cls.clickType ]: event.type === TYPES.CLICK, - [ cls.inputType ]: event.type === TYPES.INPUT, - [ cls.clickrageType ]: event.type === TYPES.CLICKRAGE, - [ cls.highlight ] : presentInSearch, - [ cls.lastInGroup ]: whiteBg, - }) } - onClick={ onClick } - > - { menuOpen && - - } -
-
- { this.renderBody() } -
- {/* { event.type === TYPES.LOCATION && -
{event.url}
- } */} -
- { event.type === TYPES.LOCATION && (event.fcpTime || event.visuallyComplete || event.timeToInteractive) && - elements / 1.2, - // eslint-disable-next-line no-mixed-operators - divisorFn: (elements, parts) => elements / (2 * parts + 1), - }) } - /> - } -
- ); - } -} diff --git a/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/EventGroupWrapper.js b/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/EventGroupWrapper.js deleted file mode 100644 index 924be9f2c..000000000 --- a/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/EventGroupWrapper.js +++ /dev/null @@ -1,130 +0,0 @@ -import React from 'react'; -import cn from 'classnames'; -import { connect } from 'react-redux' -import { TextEllipsis } from 'UI'; -import withToggle from 'HOCs/withToggle'; -import { TYPES } from 'Types/session/event'; -import Event from './Event' -import stl from './eventGroupWrapper.module.css'; -import NoteEvent from './NoteEvent'; -import { setEditNoteTooltip } from 'Duck/sessions';; - -// TODO: incapsulate toggler in LocationEvent -@withToggle('showLoadInfo', 'toggleLoadInfo') -@connect( - (state) => ({ - members: state.getIn(['members', 'list']), - currentUserId: state.getIn(['user', 'account', 'id']), - }), - { setEditNoteTooltip } -) -class EventGroupWrapper extends React.Component { - toggleLoadInfo = (e) => { - e.stopPropagation(); - this.props.toggleLoadInfo(); - }; - - componentDidUpdate(prevProps) { - if ( - prevProps.showLoadInfo !== this.props.showLoadInfo || - prevProps.query !== this.props.query || - prevProps.event.timestamp !== this.props.event.timestamp || - prevProps.isNote !== this.props.isNote - ) { - this.props.mesureHeight(); - } - } - componentDidMount() { - this.props.toggleLoadInfo(this.props.isFirst); - this.props.mesureHeight(); - } - - onEventClick = (e) => this.props.onEventClick(e, this.props.event); - - onCheckboxClick = (e) => this.props.onCheckboxClick(e, this.props.event); - - render() { - const { - event, - isLastEvent, - isLastInGroup, - isSelected, - isCurrent, - isEditing, - showSelection, - showLoadInfo, - isFirst, - presentInSearch, - isNote, - filterOutNote, - } = this.props; - const isLocation = event.type === TYPES.LOCATION; - - const whiteBg = - (isLastInGroup && event.type !== TYPES.LOCATION) || - (!isLastEvent && event.type !== TYPES.LOCATION); - const safeRef = String(event.referrer || ''); - - return ( -
- {isFirst && isLocation && event.referrer && ( -
- - Referrer: {safeRef} - -
- )} - {isNote ? ( - - ) : isLocation ? ( - - ) : ( - - )} -
- ); - } -} - -export default EventGroupWrapper diff --git a/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/EventsBlock.tsx b/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/EventsBlock.tsx deleted file mode 100644 index 5421ebbc4..000000000 --- a/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/EventsBlock.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import cn from 'classnames'; -import { Icon } from 'UI'; -import { List, AutoSizer, CellMeasurer } from "react-virtualized"; -import { TYPES } from 'Types/session/event'; -import { setEventFilter, filterOutNote } from 'Duck/sessions'; -import EventGroupWrapper from './EventGroupWrapper'; -import styles from './eventsBlock.module.css'; -import EventSearch from './EventSearch/EventSearch'; -import { PlayerContext } from 'App/components/Session/playerContext'; -import { observer } from 'mobx-react-lite'; -import { RootStore } from 'App/duck' -import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache' -import { InjectedEvent } from 'Types/session/event' -import Session from 'Types/session' - -interface IProps { - setEventFilter: (filter: { query: string }) => void - filteredEvents: InjectedEvent[] - setActiveTab: (tab?: string) => void - query: string - events: Session['events'] - notesWithEvents: Session['notesWithEvents'] - filterOutNote: (id: string) => void - eventsIndex: number[] -} - -function EventsBlock(props: IProps) { - const [mouseOver, setMouseOver] = React.useState(true) - const scroller = React.useRef(null) - const cache = useCellMeasurerCache( { - fixedWidth: true, - defaultHeight: 300 - }); - - const { store, player } = React.useContext(PlayerContext) - - const { eventListNow, playing } = store.get() - - const { - filteredEvents, - eventsIndex, - filterOutNote, - query, - setActiveTab, - events, - notesWithEvents, - } = props - - const currentTimeEventIndex = eventListNow.length > 0 ? eventListNow.length - 1 : 0 - const usedEvents = filteredEvents || notesWithEvents - - const write = ({ target: { value } }: React.ChangeEvent) => { - props.setEventFilter({ query: value }) - - setTimeout(() => { - if (!scroller.current) return; - - scroller.current.scrollToRow(0); - }, 100) - } - - const clearSearch = () => { - props.setEventFilter({ query: '' }) - if (scroller.current) { - scroller.current.forceUpdateGrid(); - } - - setTimeout(() => { - if (!scroller.current) return; - - scroller.current.scrollToRow(0); - }, 100) - } - - React.useEffect(() => { - return () => { - clearSearch() - } - }, []) - React.useEffect(() => { - if (scroller.current) { - scroller.current.forceUpdateGrid(); - if (!mouseOver) { - scroller.current.scrollToRow(currentTimeEventIndex); - } - } - }, [currentTimeEventIndex]) - - const onEventClick = (_: React.MouseEvent, event: { time: number }) => player.jump(event.time) - const onMouseOver = () => setMouseOver(true) - const onMouseLeave = () => setMouseOver(false) - - const renderGroup = ({ index, key, style, parent }: { index: number; key: string; style: React.CSSProperties; parent: any }) => { - const isLastEvent = index === usedEvents.length - 1; - const isLastInGroup = isLastEvent || usedEvents[index + 1]?.type === TYPES.LOCATION; - const event = usedEvents[index]; - const isNote = 'noteId' in event - const isCurrent = index === currentTimeEventIndex; - - const heightBug = index === 0 && event?.type === TYPES.LOCATION && 'referrer' in event ? { top: 2 } : {} - return ( - - {({measure, registerChild}) => ( -
- -
- )} -
- ); - } - - const isEmptySearch = query && (usedEvents.length === 0 || !usedEvents) - return ( - <> -
-
- User Events { events.length }
- } - /> -
-
-
- {isEmptySearch && ( -
- - No Matching Results -
- )} - - {({ height }) => ( - - )} - -
- - ); -} - -export default connect((state: RootStore) => ({ - session: state.getIn([ 'sessions', 'current' ]), - notesWithEvents: state.getIn([ 'sessions', 'current' ]).notesWithEvents, - events: state.getIn([ 'sessions', 'current' ]).events, - filteredEvents: state.getIn([ 'sessions', 'filteredEvents' ]), - query: state.getIn(['sessions', 'eventsQuery']), - eventsIndex: state.getIn([ 'sessions', 'eventsIndex' ]), -}), { - setEventFilter, - filterOutNote -})(observer(EventsBlock)) diff --git a/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/LoadInfo.js b/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/LoadInfo.js deleted file mode 100644 index 664caeb9b..000000000 --- a/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/LoadInfo.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import styles from './loadInfo.module.css'; -import { numberWithCommas } from 'App/utils' - -const LoadInfo = ({ showInfo = false, onClick, event: { fcpTime, visuallyComplete, timeToInteractive }, prorata: { a, b, c } }) => ( -
-
- { typeof fcpTime === 'number' &&
} - { typeof visuallyComplete === 'number' &&
} - { typeof timeToInteractive === 'number' &&
} -
-
- { typeof fcpTime === 'number' && -
-
-
{ 'Time to Render' }
-
{ `${ numberWithCommas(fcpTime || 0) }ms` }
-
- } - { typeof visuallyComplete === 'number' && -
-
-
{ 'Visually Complete' }
-
{ `${ numberWithCommas(visuallyComplete || 0) }ms` }
-
- } - { typeof timeToInteractive === 'number' && -
-
-
{ 'Time To Interactive' }
-
{ `${ numberWithCommas(timeToInteractive || 0) }ms` }
-
- } -
-
-); - -LoadInfo.displayName = 'LoadInfo'; - -export default LoadInfo; diff --git a/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/NoteEvent.tsx b/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/NoteEvent.tsx deleted file mode 100644 index a09869ba5..000000000 --- a/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/NoteEvent.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React from 'react'; -import { Icon } from 'UI'; -import { tagProps, Note } from 'App/services/NotesService'; -import { formatTimeOrDate } from 'App/date'; -import { useStore } from 'App/mstore'; -import { observer } from 'mobx-react-lite'; -import { ItemMenu } from 'UI'; -import copy from 'copy-to-clipboard'; -import { toast } from 'react-toastify'; -import { session } from 'App/routes'; -import { confirm } from 'UI'; -import { TeamBadge } from 'Shared/SessionListContainer/components/Notes'; - -interface Props { - note: Note; - noEdit: boolean; - filterOutNote: (id: number) => void; - onEdit: (noteTooltipObj: Record) => void; -} - -function NoteEvent(props: Props) { - const { settingsStore, notesStore } = useStore(); - const { timezone } = settingsStore.sessionSettings; - - const onEdit = () => { - props.onEdit({ - isVisible: true, - isEdit: true, - time: props.note.timestamp, - note: { - timestamp: props.note.timestamp, - tag: props.note.tag, - isPublic: props.note.isPublic, - message: props.note.message, - sessionId: props.note.sessionId, - noteId: props.note.noteId, - }, - }); - }; - - const onCopy = () => { - copy( - `${window.location.origin}/${window.location.pathname.split('/')[1]}${session( - props.note.sessionId - )}${props.note.timestamp > 0 ? `?jumpto=${props.note.timestamp}¬e=${props.note.noteId}` : `?note=${props.note.noteId}`}` - ); - toast.success('Note URL copied to clipboard'); - }; - - const onDelete = async () => { - if ( - await confirm({ - header: 'Confirm', - confirmButton: 'Yes, delete', - confirmation: `Are you sure you want to delete this note?`, - }) - ) { - notesStore.deleteNote(props.note.noteId).then((r) => { - props.filterOutNote(props.note.noteId); - toast.success('Note deleted'); - }); - } - }; - const menuItems = [ - { icon: 'pencil', text: 'Edit', onClick: onEdit, disabled: props.noEdit }, - { icon: 'link-45deg', text: 'Copy URL', onClick: onCopy }, - { icon: 'trash', text: 'Delete', onClick: onDelete }, - ]; - return ( -
-
-
- -
-
-
- {props.note.userName} -
-
- {formatTimeOrDate(props.note.createdAt as unknown as number, timezone)} -
-
-
- -
-
-
- {props.note.message} -
-
-
- {props.note.tag ? ( -
- {props.note.tag} -
- ) : null} - {!props.note.isPublic ? null : } -
-
-
- ); -} - -export default observer(NoteEvent); diff --git a/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/index.js b/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/index.js deleted file mode 100644 index 47e4d4efb..000000000 --- a/frontend/app/components/Session/Player/ReplayPlayer/EventsBlock/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './EventsBlock'; \ No newline at end of file diff --git a/frontend/app/components/Session/Player/SharedComponents/Tab.tsx b/frontend/app/components/Session/Player/SharedComponents/Tab.tsx new file mode 100644 index 000000000..8586fb2f1 --- /dev/null +++ b/frontend/app/components/Session/Player/SharedComponents/Tab.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import cn from 'classnames'; + +interface Props { + i: number; + tab: string; + currentTab: string; + changeTab?: (tab: string) => void; +} + +function Tab({ i, tab, currentTab, changeTab }: Props) { + return ( +
changeTab?.(tab)} + className={cn( + 'self-end py-1 px-4 cursor-pointer', + currentTab === tab + ? 'border-gray-light border-t border-l border-r !border-b-white bg-white rounded-tl rounded-tr font-semibold' + : 'cursor-pointer border-gray-light !border-b !border-t-0 !border-l-0 !border-r-0' + )} + > + Tab {i + 1} +
+ ); +} + +export default Tab; diff --git a/frontend/app/components/Session_/BugReport/BugReportModal.tsx b/frontend/app/components/Session_/BugReport/BugReportModal.tsx index f43716b5b..62a0f5ac5 100644 --- a/frontend/app/components/Session_/BugReport/BugReportModal.tsx +++ b/frontend/app/components/Session_/BugReport/BugReportModal.tsx @@ -113,7 +113,7 @@ function BugReportModal({ hideModal, session, width, height, account, xrayProps, // REQUIRED FOR FUTURE USAGE AND AS AN EXAMPLE OF THE FUNCTIONALITY function buildPng() { - html2canvas(reportRef.current, { + html2canvas(reportRef.current!, { scale: 2, ignoreElements: (e) => e.id.includes('pdf-ignore'), }).then((canvas) => { @@ -147,11 +147,11 @@ function BugReportModal({ hideModal, session, width, height, account, xrayProps, } function buildText() { doc - .html(reportRef.current, { + .html(reportRef.current!, { x: 0, y: 0, width: 210, - windowWidth: reportRef.current.getBoundingClientRect().width, + windowWidth: reportRef.current!.getBoundingClientRect().width, autoPaging: 'text', html2canvas: { ignoreElements: (e) => e.id.includes('pdf-ignore') || e instanceof SVGElement, diff --git a/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js b/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js index 44efb5b75..21d425e73 100644 --- a/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js +++ b/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js @@ -1,7 +1,7 @@ import React from 'react'; import cn from 'classnames'; import { connect } from 'react-redux'; -import { TextEllipsis } from 'UI'; +import { TextEllipsis, Icon } from 'UI'; import withToggle from 'HOCs/withToggle'; import { TYPES } from 'Types/session/event'; import Event from './Event'; @@ -57,6 +57,7 @@ class EventGroupWrapper extends React.Component { isFirst, presentInSearch, isNote, + isTabChange, filterOutNote } = this.props; const isLocation = event.type === TYPES.LOCATION; @@ -107,7 +108,7 @@ class EventGroupWrapper extends React.Component { isLastInGroup={isLastInGroup} whiteBg={true} /> - ) : ( + ) : isTabChange ? () : ( )}
- {isLastInGroup &&
} + {(isLastInGroup && !isTabChange) &&
} ); } } +function TabChange({ from, to }) { return ( +
+ Tab change: + + {from} + + + + {to} + +
+) +} + export default EventGroupWrapper; diff --git a/frontend/app/components/Session_/EventsBlock/EventsBlock.tsx b/frontend/app/components/Session_/EventsBlock/EventsBlock.tsx index 54e6e87e9..05ae4e856 100644 --- a/frontend/app/components/Session_/EventsBlock/EventsBlock.tsx +++ b/frontend/app/components/Session_/EventsBlock/EventsBlock.tsx @@ -13,7 +13,7 @@ import { observer } from 'mobx-react-lite'; import { RootStore } from 'App/duck'; import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache'; import { InjectedEvent } from 'Types/session/event'; -import Session from 'Types/session'; +import Session, { mergeEventLists } from 'Types/session'; interface IProps { setEventFilter: (filter: { query: string }) => void; @@ -29,14 +29,14 @@ interface IProps { function EventsBlock(props: IProps) { const [mouseOver, setMouseOver] = React.useState(true); const scroller = React.useRef(null); - const cache = useCellMeasurerCache( { + const cache = useCellMeasurerCache({ fixedWidth: true, defaultHeight: 300, }); const { store, player } = React.useContext(PlayerContext); - const { eventListNow, playing } = store.get(); + const { playing, tabStates, tabChangeEvents } = store.get(); const { filteredEvents, @@ -44,12 +44,19 @@ function EventsBlock(props: IProps) { filterOutNote, query, setActiveTab, - events, - notesWithEvents, + notesWithEvents = [], } = props; + const filteredLength = filteredEvents?.length || 0; + const notesWithEvtsLength = notesWithEvents?.length || 0; + const eventListNow = Object.values(tabStates).reduce((acc: any[], tab) => { + return acc.concat(tab.eventListNow) + }, []) + const currentTimeEventIndex = eventListNow.length > 0 ? eventListNow.length - 1 : 0; - const usedEvents = filteredEvents || notesWithEvents; + const usedEvents = React.useMemo(() => { + return mergeEventLists(filteredEvents || notesWithEvents, tabChangeEvents); + }, [filteredLength, notesWithEvtsLength]) const write = ({ target: { value } }: React.ChangeEvent) => { props.setEventFilter({ query: value }); @@ -110,6 +117,7 @@ function EventsBlock(props: IProps) { const isLastInGroup = isLastEvent || usedEvents[index + 1]?.type === TYPES.LOCATION; const event = usedEvents[index]; const isNote = 'noteId' in event; + const isTabChange = event.type === 'TABCHANGE'; const isCurrent = index === currentTimeEventIndex; const heightBug = @@ -130,6 +138,7 @@ function EventsBlock(props: IProps) { isCurrent={isCurrent} showSelection={!playing} isNote={isNote} + isTabChange={isTabChange} filterOutNote={filterOutNote} />
diff --git a/frontend/app/components/Session_/Exceptions/Exceptions.tsx b/frontend/app/components/Session_/Exceptions/Exceptions.tsx index 987d0f215..969a02271 100644 --- a/frontend/app/components/Session_/Exceptions/Exceptions.tsx +++ b/frontend/app/components/Session_/Exceptions/Exceptions.tsx @@ -25,7 +25,8 @@ interface IProps { function Exceptions({ errorStack, sourcemapUploaded, loading }: IProps) { const { player, store } = React.useContext(PlayerContext); - const { logListNow: logs, exceptionsList: exceptions } = store.get(); + const { tabStates, currentTab } = store.get(); + const { logListNow: logs = [], exceptionsList: exceptions = [] } = tabStates[currentTab] const [filter, setFilter] = React.useState(''); const [currentError, setCurrentErrorVal] = React.useState(null); diff --git a/frontend/app/components/Session_/GraphQL/GraphQL.tsx b/frontend/app/components/Session_/GraphQL/GraphQL.tsx index 11f46d318..8cafc95d3 100644 --- a/frontend/app/components/Session_/GraphQL/GraphQL.tsx +++ b/frontend/app/components/Session_/GraphQL/GraphQL.tsx @@ -14,7 +14,8 @@ function renderDefaultStatus() { function GraphQL() { const { player, store } = React.useContext(PlayerContext); - const { graphqlList: list, graphqlListNow: listNow, time, livePlay } = store.get(); + const { time, livePlay, tabStates, currentTab } = store.get(); + const { graphqlList: list = [], graphqlListNow: listNow = [] } = tabStates[currentTab] const defaultState = { filter: '', diff --git a/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx b/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx index 8ff9af3f3..3722f6c2c 100644 --- a/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx +++ b/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx @@ -25,15 +25,19 @@ function OverviewPanel({ issuesList }: { issuesList: Record[] }) { const { endTime, - performanceChartData, - stackList: stackEventList, - eventList: eventsList, - frustrationsList, - exceptionsList, - resourceList: resourceListUnmap, - fetchList, - graphqlList, + currentTab, + tabStates, } = store.get(); + const states = Object.values(tabStates) + + const stackEventList = tabStates[currentTab]?.stackList || [] + const eventsList = tabStates[currentTab]?.eventList || [] + const frustrationsList = tabStates[currentTab]?.frustrationsList || [] + const exceptionsList = tabStates[currentTab]?.exceptionsList || [] + const resourceListUnmap = tabStates[currentTab]?.resourceList || [] + const fetchList = tabStates[currentTab]?.fetchList || [] + const graphqlList = tabStates[currentTab]?.graphqlList || [] + const performanceChartData = tabStates[currentTab]?.performanceChartData || [] const fetchPresented = fetchList.length > 0; @@ -50,7 +54,7 @@ function OverviewPanel({ issuesList }: { issuesList: Record[] }) { PERFORMANCE: performanceChartData, FRUSTRATIONS: frustrationsList, }; - }, [dataLoaded]); + }, [dataLoaded, currentTab]); useEffect(() => { if (dataLoaded) { @@ -67,7 +71,7 @@ function OverviewPanel({ issuesList }: { issuesList: Record[] }) { ) { setDataLoaded(true); } - }, [resourceList, issuesList, exceptionsList, eventsList, stackEventList, performanceChartData]); + }, [resourceList, issuesList, exceptionsList, eventsList, stackEventList, performanceChartData, currentTab]); return ( diff --git a/frontend/app/components/Session_/Performance/Performance.tsx b/frontend/app/components/Session_/Performance/Performance.tsx index 330defb26..727683060 100644 --- a/frontend/app/components/Session_/Performance/Performance.tsx +++ b/frontend/app/components/Session_/Performance/Performance.tsx @@ -21,6 +21,7 @@ import stl from './performance.module.css'; import BottomBlock from '../BottomBlock'; import InfoLine from '../BottomBlock/InfoLine'; +import { toJS } from "mobx"; const CPU_VISUAL_OFFSET = 10; @@ -183,17 +184,22 @@ function Performance({ const [_data, setData] = React.useState([]) const { - performanceChartTime, - performanceChartData, connType, connBandwidth, - performanceAvailability: availability, + tabStates, + currentTab, } = store.get(); - React.useState(() => { + const { + performanceChartTime = [], + performanceChartData = [], + performanceAvailability: availability = {} + } = tabStates[currentTab]; + + React.useEffect(() => { setTicks(generateTicks(performanceChartData)); setData(addFpsMetadata(performanceChartData)); - }) + }, [currentTab]) const onDotClick = ({ index: pointer }: { index: number }) => { diff --git a/frontend/app/components/Session_/Player/Controls/Controls.tsx b/frontend/app/components/Session_/Player/Controls/Controls.tsx index 5f8665f72..8ee1a8487 100644 --- a/frontend/app/components/Session_/Player/Controls/Controls.tsx +++ b/frontend/app/components/Session_/Player/Controls/Controls.tsx @@ -1,7 +1,7 @@ import React from 'react'; import cn from 'classnames'; import { connect } from 'react-redux'; -import { selectStorageType, STORAGE_TYPES } from 'Player'; +import { selectStorageType, STORAGE_TYPES, StorageType } from 'Player'; import { PlayButton, PlayingState, FullScreenButton } from 'App/player-ui' import { Icon, Tooltip } from 'UI'; @@ -67,17 +67,21 @@ function Controls(props: any) { completed, skip, speed, - cssLoading, messagesLoading, inspectorMode, markedTargets, - exceptionsList, - profilesList, - graphqlList, - logMarkedCountNow: logRedCount, - resourceMarkedCountNow: resourceRedCount, - stackMarkedCountNow: stackRedCount, + currentTab, + tabStates } = store.get(); + + const cssLoading = tabStates[currentTab]?.cssLoading ?? false; + const profilesList = tabStates[currentTab]?.profilesList || []; + const graphqlList = tabStates[currentTab]?.graphqlList || []; + const logRedCount = tabStates[currentTab]?.logMarkedCountNow || 0; + const resourceRedCount = tabStates[currentTab]?.resourceMarkedCountNow || 0; + const stackRedCount = tabStates[currentTab]?.stackMarkedCountNow || 0; + const exceptionsList = tabStates[currentTab]?.exceptionsList || []; + const { bottomBlock, toggleBottomBlock, @@ -86,10 +90,10 @@ function Controls(props: any) { skipInterval, disabledRedux, showStorageRedux, - session + session, } = props; - - const storageType = selectStorageType(store.get()); + + const storageType = store.get().tabStates[currentTab] ? selectStorageType(store.get().tabStates[currentTab]) : StorageType.NONE const disabled = disabledRedux || cssLoading || messagesLoading || inspectorMode || markedTargets; const profilesCount = profilesList.length; const graphqlCount = graphqlList.length; diff --git a/frontend/app/components/Session_/Player/Controls/Timeline.tsx b/frontend/app/components/Session_/Player/Controls/Timeline.tsx index e456df08a..7dee7cab0 100644 --- a/frontend/app/components/Session_/Player/Controls/Timeline.tsx +++ b/frontend/app/components/Session_/Player/Controls/Timeline.tsx @@ -13,6 +13,7 @@ import { observer } from 'mobx-react-lite'; import { useStore } from 'App/mstore'; import { DateTime, Duration } from 'luxon'; import Issue from "Types/session/issue"; +import { toJS } from 'mobx' function getTimelinePosition(value: number, scale: number) { const pos = value * scale; @@ -35,20 +36,21 @@ function Timeline(props: IProps) { playing, time, skipIntervals, - eventList: events, skip, skipToIssue, ready, endTime, devtoolsLoading, domLoading, + tabStates, + currentTab, } = store.get() const { issues } = props; const notes = notesStore.sessionNotes const progressRef = useRef(null) const timelineRef = useRef(null) - + const events = tabStates[currentTab]?.eventList || []; const scale = 100 / endTime; diff --git a/frontend/app/components/Session_/Player/Controls/controls.module.css b/frontend/app/components/Session_/Player/Controls/controls.module.css index c27cb74da..ccbf6e0bb 100644 --- a/frontend/app/components/Session_/Player/Controls/controls.module.css +++ b/frontend/app/components/Session_/Player/Controls/controls.module.css @@ -15,7 +15,7 @@ display: flex; justify-content: space-between; align-items: center; - height: 65px; + height: 55px; padding-left: 10px; padding-right: 0; } diff --git a/frontend/app/components/Session_/Player/Overlay.tsx b/frontend/app/components/Session_/Player/Overlay.tsx index ff0344757..85fed07e6 100644 --- a/frontend/app/components/Session_/Player/Overlay.tsx +++ b/frontend/app/components/Session_/Player/Overlay.tsx @@ -22,13 +22,14 @@ function Overlay({ const { playing, messagesLoading, - cssLoading, completed, autoplay, inspectorMode, markedTargets, activeTargetIndex, + tabStates, } = store.get() + const cssLoading = Object.values(tabStates).some(({ cssLoading }) => cssLoading) const loading = messagesLoading || cssLoading const showAutoplayTimer = completed && autoplay && nextId diff --git a/frontend/app/components/Session_/Storage/Storage.tsx b/frontend/app/components/Session_/Storage/Storage.tsx index 8f7f3e92e..d433135ac 100644 --- a/frontend/app/components/Session_/Storage/Storage.tsx +++ b/frontend/app/components/Session_/Storage/Storage.tsx @@ -5,6 +5,7 @@ import { PlayerContext } from 'App/components/Session/playerContext'; import { observer } from 'mobx-react-lite'; import { JSONTree, NoContent, Tooltip } from 'UI'; import { formatMs } from 'App/date'; +// @ts-ignore import { diff } from 'deep-diff'; import { STORAGE_TYPES, selectStorageList, selectStorageListNow, selectStorageType } from 'Player'; import Autoscroll from '../Autoscroll'; @@ -40,12 +41,28 @@ interface Props { function Storage(props: Props) { const lastBtnRef = React.useRef(); const [showDiffs, setShowDiffs] = React.useState(false); - const { player, store } = React.useContext(PlayerContext); - const state = store.get(); + const [stateObject, setState] = React.useState({}); - const listNow = selectStorageListNow(state); - const list = selectStorageList(state); - const type = selectStorageType(state); + const { player, store } = React.useContext(PlayerContext); + const { tabStates, currentTab } = store.get() + const state = tabStates[currentTab] || {} + + const listNow = selectStorageListNow(state) || []; + const list = selectStorageList(state) || []; + const type = selectStorageType(state) || STORAGE_TYPES.NONE + + React.useEffect(() => { + let currentState; + if (listNow.length === 0) { + currentState = decodeMessage(list[0]) + } else { + currentState = decodeMessage(listNow[listNow.length - 1]) + } + const stateObj = currentState?.state || currentState?.payload?.state || {} + const newState = Object.assign(stateObject, stateObj); + setState(newState); + + }, [listNow.length]); const decodeMessage = (msg: any) => { const decoded = {}; @@ -84,7 +101,11 @@ function Storage(props: Props) { focusNextButton(); }, [listNow]); - const renderDiff = (item: Record, prevItem: Record) => { + const renderDiff = (item: Record, prevItem?: Record) => { + if (!showDiffs) { + return; + } + if (!prevItem) { // we don't have state before first action return
; @@ -166,7 +187,7 @@ function Storage(props: Props) { name = itemD.mutation.join(''); } - if (src !== null && !showDiffs) { + if (src !== null && !showDiffs && itemD.state) { setShowDiffs(true); } @@ -182,7 +203,7 @@ function Storage(props: Props) { ) : ( <> {renderDiff(itemD, prevItemD)} -
+
{list.length > 0 && (
- {showStore && ( -

- {'STATE'} -

- )} +

+ {'STATE'} +

{showDiffs ? (

DIFFS @@ -311,22 +329,17 @@ function Storage(props: Props) { size="small" show={list.length === 0} > - {showStore && ( -
- {list.length === 0 ? ( -
- {'Empty state.'} -
- ) : ( - - )} -
- )} -
+
+ {list.length === 0 ? ( +
+ {'Empty state.'} +
+ ) : ( + + )} +
+
{decodedList.map((item: Record, i: number) => renderItem(item, i, i > 0 ? decodedList[i - 1] : undefined) diff --git a/frontend/app/components/Session_/Subheader.js b/frontend/app/components/Session_/Subheader.js index 160131728..1a9322188 100644 --- a/frontend/app/components/Session_/Subheader.js +++ b/frontend/app/components/Session_/Subheader.js @@ -11,152 +11,162 @@ import BugReportModal from './BugReport/BugReportModal'; import { PlayerContext } from 'App/components/Session/playerContext'; import { observer } from 'mobx-react-lite'; import AutoplayToggle from 'Shared/AutoplayToggle'; -import { connect } from 'react-redux' +import { connect } from 'react-redux'; +import Tab from 'Components/Session/Player/SharedComponents/Tab'; -const localhostWarn = (project) => project + '_localhost_warn' +const localhostWarn = (project) => project + '_localhost_warn'; function SubHeader(props) { - const localhostWarnKey = localhostWarn(props.siteId) - const defaultLocalhostWarn = localStorage.getItem(localhostWarnKey) !== '1' - const [showWarningModal, setWarning] = React.useState(defaultLocalhostWarn); - const { player, store } = React.useContext(PlayerContext); - const { - width, - height, - location: currentLocation, - fetchList, - graphqlList, - resourceList, - exceptionsList, - eventList: eventsList, - endTime, - } = store.get(); + const localhostWarnKey = localhostWarn(props.siteId); + const defaultLocalhostWarn = localStorage.getItem(localhostWarnKey) !== '1'; + const [showWarningModal, setWarning] = React.useState(defaultLocalhostWarn); + const { player, store } = React.useContext(PlayerContext); + const { width, height, endTime, tabStates, currentTab, tabs } = store.get(); - const enabledIntegration = useMemo(() => { - const { integrations } = props; - if (!integrations || !integrations.size) { - return false; - } - - return integrations.some((i) => i.token); - }) + const currentLocation = tabStates[currentTab]?.location || ''; + const resourceList = tabStates[currentTab]?.resourceList || []; + const exceptionsList = tabStates[currentTab]?.exceptionsList || []; + const eventsList = tabStates[currentTab]?.eventList || []; + const graphqlList = tabStates[currentTab]?.graphqlList || []; + const fetchList = tabStates[currentTab]?.fetchList || []; - const mappedResourceList = resourceList - .filter((r) => r.isRed || r.isYellow) - .concat(fetchList.filter((i) => parseInt(i.status) >= 400)) - .concat(graphqlList.filter((i) => parseInt(i.status) >= 400)); + const enabledIntegration = useMemo(() => { + const { integrations } = props; + if (!integrations || !integrations.size) { + return false; + } - const { showModal, hideModal } = useModal(); + return integrations.some((i) => i.token); + }); - const location = - currentLocation && currentLocation.length > 70 - ? `${currentLocation.slice(0, 25)}...${currentLocation.slice(-40)}` - : currentLocation; + const mappedResourceList = resourceList + .filter((r) => r.isRed || r.isYellow) + .concat(fetchList.filter((i) => parseInt(i.status) >= 400)) + .concat(graphqlList.filter((i) => parseInt(i.status) >= 400)); - const showReportModal = () => { - player.pause(); - const xrayProps = { - currentLocation: currentLocation, - resourceList: mappedResourceList, - exceptionsList: exceptionsList, - eventsList: eventsList, - endTime: endTime, + const { showModal, hideModal } = useModal(); + + const location = + currentLocation && currentLocation.length > 70 + ? `${currentLocation.slice(0, 25)}...${currentLocation.slice(-40)}` + : currentLocation; + + const showReportModal = () => { + player.pause(); + const xrayProps = { + currentLocation: currentLocation, + resourceList: mappedResourceList, + exceptionsList: exceptionsList, + eventsList: eventsList, + endTime: endTime, + }; + showModal( + , + { right: true, width: 620 } + ); }; - showModal( - , - { right: true, width: 620 } - ); - }; - const showWarning = - location && /(localhost)|(127.0.0.1)|(0.0.0.0)/.test(location) && showWarningModal; - const closeWarning = () => { - localStorage.setItem(localhostWarnKey, '1') - setWarning(false) - } - return ( -
- {showWarning ? ( -
- Some assets may load incorrectly on localhost. - - Learn More - -
- -
-
- ) : null} - {location && ( + const showWarning = + location && /(localhost)|(127.0.0.1)|(0.0.0.0)/.test(location) && showWarningModal; + const closeWarning = () => { + localStorage.setItem(localhostWarnKey, '1'); + setWarning(false); + }; + return ( <> -
- - - {location} - -
- - )} -
- - - {enabledIntegration && } - - -
- } - /> - , - }, - { - key: 2, - component: , - }, - ]} - /> +
+ {showWarning ? ( +
+ Some assets may load incorrectly on localhost. + + Learn More + +
+ +
+
+ ) : null} + {tabs.map((tab, i) => ( + + player.changeTab(changeTo)} + /> + + ))} +
+ + + {enabledIntegration && } + + +
+ } + /> + , + }, + { + key: 2, + component: , + }, + ]} + /> -
- -
-
-
- ); +
+ +
+
+
+ {location && ( +
+ +
+ )} + + ); } export default connect((state) => ({ - siteId: state.getIn(['site', 'siteId']), - integrations: state.getIn([ 'issues', 'list' ]) + siteId: state.getIn(['site', 'siteId']), + integrations: state.getIn(['issues', 'list']), }))(observer(SubHeader)); diff --git a/frontend/app/components/shared/DevTools/ConsolePanel/ConsolePanel.tsx b/frontend/app/components/shared/DevTools/ConsolePanel/ConsolePanel.tsx index 78cd97277..a54dbeb3b 100644 --- a/frontend/app/components/shared/DevTools/ConsolePanel/ConsolePanel.tsx +++ b/frontend/app/components/shared/DevTools/ConsolePanel/ConsolePanel.tsx @@ -13,6 +13,7 @@ import { useModal } from 'App/components/Modal'; import useAutoscroll, { getLastItemTime } from '../useAutoscroll'; import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter' import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache' +import { toJS } from 'mobx' const ALL = 'ALL'; const INFO = 'INFO'; @@ -74,7 +75,9 @@ function ConsolePanel({ isLive }: { isLive: boolean }) { const { player, store } = React.useContext(PlayerContext) const jump = (t: number) => player.jump(t) - const { logList, exceptionsList, logListNow, exceptionsListNow } = store.get() + const { currentTab, tabStates } = store.get() + const { logList = [], exceptionsList = [], logListNow = [], exceptionsListNow = [] } = tabStates[currentTab] + const list = isLive ? useMemo(() => logListNow.concat(exceptionsListNow).sort((a, b) => a.time - b.time), [logListNow.length, exceptionsListNow.length] diff --git a/frontend/app/components/shared/DevTools/NetworkPanel/NetworkPanel.tsx b/frontend/app/components/shared/DevTools/NetworkPanel/NetworkPanel.tsx index 373f44746..0b0ccb209 100644 --- a/frontend/app/components/shared/DevTools/NetworkPanel/NetworkPanel.tsx +++ b/frontend/app/components/shared/DevTools/NetworkPanel/NetworkPanel.tsx @@ -151,11 +151,16 @@ function NetworkPanel({ startedAt }: { startedAt: number }) { domContentLoadedTime, loadTime, domBuildingTime, - fetchList, - resourceList, - fetchListNow, - resourceListNow, + tabStates, + currentTab } = store.get() + const { + fetchList = [], + resourceList = [], + fetchListNow = [], + resourceListNow = [] + } = tabStates[currentTab] + const { showModal } = useModal(); const [sortBy, setSortBy] = useState('time'); const [sortAscending, setSortAscending] = useState(true); diff --git a/frontend/app/components/shared/DevTools/ProfilerPanel/ProfilerPanel.tsx b/frontend/app/components/shared/DevTools/ProfilerPanel/ProfilerPanel.tsx index 0b7a8bfc1..e4a0b2f58 100644 --- a/frontend/app/components/shared/DevTools/ProfilerPanel/ProfilerPanel.tsx +++ b/frontend/app/components/shared/DevTools/ProfilerPanel/ProfilerPanel.tsx @@ -15,8 +15,8 @@ const renderName = (p: any) => ; function ProfilerPanel() { const { store } = React.useContext(PlayerContext) - - const profiles = store.get().profilesList as any[] // TODO lest internal types + const { tabStates, currentTab } = store.get() + const profiles = tabStates[currentTab].profilesList || [] as any[] // TODO lest internal types const { showModal } = useModal(); const [ filter, onFilterChange ] = useInputState() diff --git a/frontend/app/components/shared/DevTools/StackEventPanel/StackEventPanel.tsx b/frontend/app/components/shared/DevTools/StackEventPanel/StackEventPanel.tsx index 109e29c97..16b6b8289 100644 --- a/frontend/app/components/shared/DevTools/StackEventPanel/StackEventPanel.tsx +++ b/frontend/app/components/shared/DevTools/StackEventPanel/StackEventPanel.tsx @@ -22,7 +22,12 @@ const TABS = TAB_KEYS.map((tab) => ({ text: tab, key: tab })) function StackEventPanel() { const { player, store } = React.useContext(PlayerContext) const jump = (t: number) => player.jump(t) - const { stackList: list, stackListNow: listNow } = store.get() + const { currentTab, tabStates } = store.get() + + const { + stackList: list = [], + stackListNow: listNow = [], + } = tabStates[currentTab] const { sessionStore: { devTools }, diff --git a/frontend/app/player/common/ListWalker.ts b/frontend/app/player/common/ListWalker.ts index 61f93f8df..900f220b8 100644 --- a/frontend/app/player/common/ListWalker.ts +++ b/frontend/app/player/common/ListWalker.ts @@ -38,7 +38,7 @@ export default class ListWalker { if (this.list.length === 0) { return null; } - return this.list[ this.list.length - 1 ]; + return this.list.slice(-1)[0]; } get current(): T | null { @@ -108,7 +108,7 @@ export default class ListWalker { /** * @returns last message with the time <= t. * Assumed that the current message is already handled so - * if pointer doesn't cahnge is returned. + * if pointer doesn't change is returned. */ moveGetLast(t: number, index?: number): T | null { let key: string = "time"; //TODO @@ -130,6 +130,30 @@ export default class ListWalker { return changed ? this.list[ this.p - 1 ] : null; } + moveGetLastDebug(t: number, index?: number): T | null { + let key: string = "time"; //TODO + let val = t; + if (index) { + key = "_index"; + val = index; + } + + let changed = false; + while (this.p < this.length && this.list[this.p][key] <= val) { + this.moveNext() + changed = true; + } + while (this.p > 0 && this.list[ this.p - 1 ][key] > val) { + this.movePrev() + changed = true; + } + + // console.log(this.list[this.p - 1]) + return changed ? this.list[ this.p - 1 ] : null; + } + + + /** * Moves over the messages starting from the current+1 to the last one with the time <= t * applying callback on each of them diff --git a/frontend/app/player/web/MessageLoader.ts b/frontend/app/player/web/MessageLoader.ts index 4e35aff91..9c30d1890 100644 --- a/frontend/app/player/web/MessageLoader.ts +++ b/frontend/app/player/web/MessageLoader.ts @@ -29,6 +29,7 @@ export default class MessageLoader { private store: Store, private messageManager: MessageManager, private isClickmap: boolean, + private uiErrorHandler?: { error: (msg: string) => void } ) {} createNewParser(shouldDecrypt = true, file?: string, toggleStatus?: (isLoading: boolean) => void) { @@ -57,6 +58,9 @@ export default class MessageLoader { this.messageManager._sortMessagesHack(sorted) toggleStatus?.(false); this.messageManager.setMessagesLoading(false) + }).catch(e => { + console.error(e) + this.uiErrorHandler?.error('Error parsing file: ' + e.message) }) } diff --git a/frontend/app/player/web/MessageManager.ts b/frontend/app/player/web/MessageManager.ts index 82c294875..1a05a77c6 100644 --- a/frontend/app/player/web/MessageManager.ts +++ b/frontend/app/player/web/MessageManager.ts @@ -1,71 +1,59 @@ // @ts-ignore -import { Decoder } from "syncod"; +import { Decoder } from 'syncod'; import logger from 'App/logger'; -import { TYPES as EVENT_TYPES } from 'Types/session/event'; -import { Log } from 'Player'; -import { - ResourceType, - getResourceFromResourceTiming, - getResourceFromNetworkRequest -} from 'Player' - -import type { Store } from 'Player'; +import type { Store, ILog } from 'Player'; import ListWalker from '../common/ListWalker'; -import PagesManager from './managers/PagesManager'; import MouseMoveManager from './managers/MouseMoveManager'; -import PerformanceTrackManager from './managers/PerformanceTrackManager'; -import WindowNodeCounter from './managers/WindowNodeCounter'; import ActivityManager from './managers/ActivityManager'; -import { MouseThrashing, MType } from "./messages"; -import { isDOMType } from './messages/filters.gen'; -import type { - Message, - SetPageLocation, - ConnectionInformation, - SetViewportSize, - SetViewportScroll, - MouseClick, -} from './messages'; - -import Lists, { INITIAL_STATE as LISTS_INITIAL_STATE, State as ListsState } from './Lists'; +import { MouseThrashing, MType } from './messages'; +import type { Message, MouseClick } from './messages'; import Screen, { INITIAL_STATE as SCREEN_INITIAL_STATE, State as ScreenState, } from './Screen/Screen'; -import type { InitialLists } from './Lists' -import type { PerformanceChartPoint } from './managers/PerformanceTrackManager'; +import type { InitialLists } from './Lists'; import type { SkipInterval } from './managers/ActivityManager'; +import TabSessionManager, { TabState } from 'Player/web/TabManager'; +import ActiveTabManager from 'Player/web/managers/ActiveTabManager'; -export interface State extends ScreenState, ListsState { - performanceChartData: PerformanceChartPoint[], - skipIntervals: SkipInterval[], - connType?: string, - connBandwidth?: number, - location?: string, - performanceChartTime?: number, - performanceAvailability?: PerformanceTrackManager['availability'] - - domContentLoadedTime?: { time: number, value: number }, - domBuildingTime?: number, - loadTime?: { time: number, value: number }, - error: boolean, - messagesLoading: boolean, - cssLoading: boolean, - - ready: boolean, - lastMessageTime: number, - firstVisualEvent: number, - messagesProcessed: boolean, +interface RawList { + event: Record[] & { tabId: string | null }; + frustrations: Record[] & { tabId: string | null }; + stack: Record[] & { tabId: string | null }; + exceptions: ILog[]; } +export interface State extends ScreenState { + skipIntervals: SkipInterval[]; + connType?: string; + connBandwidth?: number; + location?: string; + tabStates: { + [tabId: string]: TabState; + }; -const visualChanges = [ + domContentLoadedTime?: { time: number; value: number }; + domBuildingTime?: number; + loadTime?: { time: number; value: number }; + error: boolean; + messagesLoading: boolean; + + ready: boolean; + lastMessageTime: number; + firstVisualEvent: number; + messagesProcessed: boolean; + currentTab: string; + tabs: string[]; + tabChangeEvents: { tabId: string; timestamp: number; tabName: string }[]; +} + +export const visualChanges = [ MType.MouseMove, MType.MouseClick, MType.CreateElementNode, @@ -73,258 +61,208 @@ const visualChanges = [ MType.SetInputChecked, MType.SetViewportSize, MType.SetViewportScroll, -] +]; export default class MessageManager { static INITIAL_STATE: State = { ...SCREEN_INITIAL_STATE, - ...LISTS_INITIAL_STATE, - performanceChartData: [], + tabStates: {}, skipIntervals: [], error: false, - cssLoading: false, ready: false, lastMessageTime: 0, firstVisualEvent: 0, messagesProcessed: false, messagesLoading: false, - } + currentTab: '', + tabs: [], + tabChangeEvents: [], + }; - private locationEventManager: ListWalker/**/ = new ListWalker(); - private locationManager: ListWalker = new ListWalker(); - private loadedLocationManager: ListWalker = new ListWalker(); - private connectionInfoManger: ListWalker = new ListWalker(); - private performanceTrackManager: PerformanceTrackManager = new PerformanceTrackManager(); - private windowNodeCounter: WindowNodeCounter = new WindowNodeCounter(); private clickManager: ListWalker = new ListWalker(); private mouseThrashingManager: ListWalker = new ListWalker(); - - private resizeManager: ListWalker = new ListWalker([]); - private pagesManager: PagesManager; + private activityManager: ActivityManager | null = null; private mouseMoveManager: MouseMoveManager; - - private scrollManager: ListWalker = new ListWalker(); + private activeTabManager = new ActiveTabManager(); public readonly decoder = new Decoder(); - private lists: Lists; - - private activityManager: ActivityManager | null = null; private readonly sessionStart: number; - private navigationStartOffset: number = 0; private lastMessageTime: number = 0; private firstVisualEventSet = false; + public readonly tabs: Record = {}; + private tabChangeEvents: Record[] = []; + private activeTab = ''; constructor( - private readonly session: any /*Session*/, - private readonly state: Store, + private readonly session: Record, + private readonly state: Store, private readonly screen: Screen, - initialLists?: Partial, - private readonly uiErrorHandler?: { error: (error: string) => void, }, + private readonly initialLists?: Partial, + private readonly uiErrorHandler?: { error: (error: string) => void } ) { - this.pagesManager = new PagesManager(screen, this.session.isMobile, this.setCSSLoading) - this.mouseMoveManager = new MouseMoveManager(screen) - - this.sessionStart = this.session.startedAt - - this.lists = new Lists(initialLists) - initialLists?.event?.forEach((e: Record) => { // TODO: to one of "Movable" module - if (e.type === EVENT_TYPES.LOCATION) { - this.locationEventManager.append(e); - } - }) - - this.activityManager = new ActivityManager(this.session.duration.milliseconds) // only if not-live + this.mouseMoveManager = new MouseMoveManager(screen); + this.sessionStart = this.session.startedAt; + this.activityManager = new ActivityManager(this.session.duration.milliseconds); // only if not-live } public getListsFullState = () => { - return this.lists.getFullListsState() - } + const fullState: Record = {}; + for (let tab in Object.keys(this.tabs)) { + fullState[tab] = this.tabs[tab].getListsFullState(); + } + return Object.values(this.tabs)[0].getListsFullState(); + }; - public updateLists(lists: Partial) { - Object.keys(lists).forEach((key: 'event' | 'stack' | 'exceptions') => { - const currentList = this.lists.lists[key] - lists[key]!.forEach(item => currentList.insert(item)) + public updateLists(lists: RawList) { + Object.keys(this.tabs).forEach((tab) => { + this.tabs[tab]!.updateLists(lists); + // once upon a time we wanted to insert events for each tab individually + // but then evil magician came and said "no, you don't want to do that" + // because it was bad for database size + // const list = { + // event: lists.event.filter((e) => e.tabId === tab), + // frustrations: lists.frustrations.filter((e) => e.tabId === tab), + // stack: lists.stack.filter((e) => e.tabId === tab), + // exceptions: lists.exceptions.filter((e) => e.tabId === tab), + // }; + // // saving some microseconds here probably + // if (Object.values(list).some((l) => l.length > 0)) { + // this.tabs[tab]!.updateLists(list); + // } }) - lists?.event?.forEach((e: Record) => { - if (e.type === EVENT_TYPES.LOCATION) { - this.locationEventManager.append(e); - } - }) - - this.state.update({ ...this.lists.getFullListsState() }); - } - - private setCSSLoading = (cssLoading: boolean) => { - this.screen.displayFrame(!cssLoading) - this.state.update({ cssLoading, ready: !this.state.get().messagesLoading && !cssLoading }) } public _sortMessagesHack = (msgs: Message[]) => { - // @ts-ignore Hack for upet (TODO: fix ordering in one mutation in tracker(removes first)) - const headChildrenIds = msgs.filter(m => m.parentID === 1).map(m => m.id); - this.pagesManager.sortPages((m1, m2) => { - if (m1.time === m2.time) { - if (m1.tp === MType.RemoveNode && m2.tp !== MType.RemoveNode) { - if (headChildrenIds.includes(m1.id)) { - return -1; - } - } else if (m2.tp === MType.RemoveNode && m1.tp !== MType.RemoveNode) { - if (headChildrenIds.includes(m2.id)) { - return 1; - } - } else if (m2.tp === MType.RemoveNode && m1.tp === MType.RemoveNode) { - const m1FromHead = headChildrenIds.includes(m1.id); - const m2FromHead = headChildrenIds.includes(m2.id); - if (m1FromHead && !m2FromHead) { - return -1; - } else if (m2FromHead && !m1FromHead) { - return 1; - } - } - } - return 0; - }) - } + Object.values(this.tabs).forEach((tab) => tab._sortMessagesHack(msgs)); + }; - private waitingForFiles: boolean = false + private waitingForFiles: boolean = false; public onFileReadSuccess = () => { - const stateToUpdate : Partial= { - performanceChartData: this.performanceTrackManager.chartData, - performanceAvailability: this.performanceTrackManager.availability, - ...this.lists.getFullListsState(), - } if (this.activityManager) { - this.activityManager.end() - stateToUpdate.skipIntervals = this.activityManager.list + this.activityManager.end(); + this.state.update({ skipIntervals: this.activityManager.list }); } - this.state.update(stateToUpdate) - } + Object.values(this.tabs).forEach((tab) => tab.onFileReadSuccess?.()); + }; public onFileReadFailed = (e: any) => { - logger.error(e) - this.state.update({ error: true }) - this.uiErrorHandler?.error('Error requesting a session file') - } + logger.error(e); + this.state.update({ error: true }); + this.uiErrorHandler?.error('Error requesting a session file'); + }; public onFileReadFinally = () => { - this.waitingForFiles = false - this.state.update({ messagesProcessed: true }) - } + this.waitingForFiles = false; + this.state.update({ messagesProcessed: true }); + }; public startLoading = () => { - this.waitingForFiles = true - this.state.update({ messagesProcessed: false }) - this.setMessagesLoading(true) - } + this.waitingForFiles = true; + this.state.update({ messagesProcessed: false }); + this.setMessagesLoading(true); + }; resetMessageManagers() { - this.locationEventManager = new ListWalker(); - this.locationManager = new ListWalker(); - this.loadedLocationManager = new ListWalker(); - this.connectionInfoManger = new ListWalker(); this.clickManager = new ListWalker(); - this.scrollManager = new ListWalker(); - this.resizeManager = new ListWalker(); - - this.performanceTrackManager = new PerformanceTrackManager() - this.windowNodeCounter = new WindowNodeCounter(); - this.pagesManager = new PagesManager(this.screen, this.session.isMobile, this.setCSSLoading) this.mouseMoveManager = new MouseMoveManager(this.screen); this.activityManager = new ActivityManager(this.session.duration.milliseconds); + this.activeTabManager = new ActiveTabManager(); + + Object.values(this.tabs).forEach((tab) => tab.resetMessageManagers()); } - move(t: number, index?: number): void { - const stateToUpdate: Partial = {}; - /* == REFACTOR_ME == */ - const lastLoadedLocationMsg = this.loadedLocationManager.moveGetLast(t, index); - if (!!lastLoadedLocationMsg) { - // TODO: page-wise resources list // setListsStartTime(lastLoadedLocationMsg.time) - this.navigationStartOffset = lastLoadedLocationMsg.navigationStart - this.sessionStart; - } - const llEvent = this.locationEventManager.moveGetLast(t, index); - if (!!llEvent) { - if (llEvent.domContentLoadedTime != null) { - stateToUpdate.domContentLoadedTime = { - time: llEvent.domContentLoadedTime + this.navigationStartOffset, //TODO: predefined list of load event for the network tab (merge events & SetPageLocation: add navigationStart to db) - value: llEvent.domContentLoadedTime, - } - } - if (llEvent.loadTime != null) { - stateToUpdate.loadTime = { - time: llEvent.loadTime + this.navigationStartOffset, - value: llEvent.loadTime, - } - } - if (llEvent.domBuildingTime != null) { - stateToUpdate.domBuildingTime = llEvent.domBuildingTime; - } - } - /* === */ - const lastLocationMsg = this.locationManager.moveGetLast(t, index); - if (!!lastLocationMsg) { - stateToUpdate.location = lastLocationMsg.url; - } - const lastConnectionInfoMsg = this.connectionInfoManger.moveGetLast(t, index); - if (!!lastConnectionInfoMsg) { - stateToUpdate.connType = lastConnectionInfoMsg.type; - stateToUpdate.connBandwidth = lastConnectionInfoMsg.downlink; - } - const lastPerformanceTrackMessage = this.performanceTrackManager.moveGetLast(t, index); - if (!!lastPerformanceTrackMessage) { - stateToUpdate.performanceChartTime = lastPerformanceTrackMessage.time; - } - - Object.assign(stateToUpdate, this.lists.moveGetState(t)) - Object.keys(stateToUpdate).length > 0 && this.state.update(stateToUpdate); - - /* Sequence of the managers is important here */ - // Preparing the size of "screen" - const lastResize = this.resizeManager.moveGetLast(t, index); - if (!!lastResize) { - this.setSize(lastResize) - } - this.pagesManager.moveReady(t).then(() => { - - const lastScroll = this.scrollManager.moveGetLast(t, index); - if (!!lastScroll && this.screen.window) { - this.screen.window.scrollTo(lastScroll.x, lastScroll.y); - } + move(t: number): any { + // usually means waiting for messages from live session + if (Object.keys(this.tabs).length === 0) return; + this.activeTabManager.moveReady(t).then((tabId) => { // Moving mouse and setting :hover classes on ready view this.mouseMoveManager.move(t); const lastClick = this.clickManager.moveGetLast(t); - if (!!lastClick && t - lastClick.time < 600) { // happened during last 600ms + if (!!lastClick && t - lastClick.time < 600) { + // happened during last 600ms this.screen.cursor.click(); } - const lastThrashing = this.mouseThrashingManager.moveGetLast(t) + const lastThrashing = this.mouseThrashingManager.moveGetLast(t); if (!!lastThrashing && t - lastThrashing.time < 300) { this.screen.cursor.shake(); } - }) - if (this.waitingForFiles && this.lastMessageTime <= t && t !== this.session.duration.milliseconds) { - this.setMessagesLoading(true) + const activeTabs = this.state.get().tabs; + if (tabId && !activeTabs.includes(tabId)) { + this.state.update({ tabs: activeTabs.concat(tabId) }); + } + + if (tabId && this.activeTab !== tabId) { + this.state.update({ currentTab: tabId }); + this.activeTab = tabId; + } + + if (this.tabs[this.activeTab]) { + this.tabs[this.activeTab].move(t); + } else { + console.error( + 'missing tab state', + this.tabs, + this.activeTab, + tabId, + this.activeTabManager.list + ); + } + }); + + if ( + this.waitingForFiles && + this.lastMessageTime <= t && + t !== this.session.duration.milliseconds + ) { + this.setMessagesLoading(true); } } + public changeTab(tabId: string) { + this.activeTab = tabId; + this.state.update({ currentTab: tabId }); + this.tabs[tabId].move(this.state.get().time); + } - distributeMessage = (msg: Message): void => { - const lastMessageTime = Math.max(msg.time, this.lastMessageTime) - this.lastMessageTime = lastMessageTime - this.state.update({ lastMessageTime }) + public updateChangeEvents() { + this.state.update({ tabChangeEvents: this.tabChangeEvents }); + } + + distributeMessage = (msg: Message & { tabId: string }): void => { + if (!this.tabs[msg.tabId]) { + this.tabs[msg.tabId] = new TabSessionManager( + this.session, + this.state, + this.screen, + msg.tabId, + this.setSize, + this.sessionStart, + this.initialLists + ); + } + + const lastMessageTime = Math.max(msg.time, this.lastMessageTime); + this.lastMessageTime = lastMessageTime; + this.state.update({ lastMessageTime }); if (visualChanges.includes(msg.tp)) { this.activityManager?.updateAcctivity(msg.time); } switch (msg.tp) { - case MType.SetPageLocation: - this.locationManager.append(msg); - if (msg.navigationStart > 0) { - this.loadedLocationManager.append(msg); + case MType.TabChange: + const prevChange = this.activeTabManager.last; + if (!prevChange || prevChange.tabId !== msg.tabId) { + this.tabChangeEvents.push({ + tabId: msg.tabId, + timestamp: this.sessionStart + msg.time, + toTab: mapTabs(this.tabs)[msg.tabId], + fromTab: prevChange?.tabId ? mapTabs(this.tabs)[prevChange.tabId] : '', + type: 'TABCHANGE', + }); + this.activeTabManager.append(msg); } break; - case MType.SetViewportSize: - this.resizeManager.append(msg); - break; case MType.MouseThrashing: this.mouseThrashingManager.append(msg); break; @@ -334,103 +272,37 @@ export default class MessageManager { case MType.MouseClick: this.clickManager.append(msg); break; - case MType.SetViewportScroll: - this.scrollManager.append(msg); - break; - case MType.PerformanceTrack: - this.performanceTrackManager.append(msg); - break; - case MType.SetPageVisibility: - this.performanceTrackManager.handleVisibility(msg) - break; - case MType.ConnectionInformation: - this.connectionInfoManger.append(msg); - break; - case MType.OTable: - this.decoder.set(msg.key, msg.value); - break; - /* Lists: */ - case MType.ConsoleLog: - if (msg.level === 'debug') break; - this.lists.lists.log.append( - // @ts-ignore : TODO: enums in the message schema - Log(msg) - ) - break; - case MType.ResourceTimingDeprecated: - case MType.ResourceTiming: - // TODO: merge `resource` and `fetch` lists into one here instead of UI - if (msg.initiator !== ResourceType.FETCH && msg.initiator !== ResourceType.XHR) { - // @ts-ignore TODO: typing for lists - this.lists.lists.resource.insert(getResourceFromResourceTiming(msg, this.sessionStart)) - } - break; - case MType.Fetch: - case MType.NetworkRequest: - this.lists.lists.fetch.insert(getResourceFromNetworkRequest(msg, this.sessionStart)) - break; - case MType.Redux: - this.lists.lists.redux.append(msg); - break; - case MType.NgRx: - this.lists.lists.ngrx.append(msg); - break; - case MType.Vuex: - this.lists.lists.vuex.append(msg); - break; - case MType.Zustand: - this.lists.lists.zustand.append(msg) - break - case MType.MobX: - this.lists.lists.mobx.append(msg); - break; - case MType.GraphQl: - this.lists.lists.graphql.append(msg); - break; - case MType.Profiler: - this.lists.lists.profiles.append(msg); - break; - /* ===|=== */ default: switch (msg.tp) { case MType.CreateDocument: if (!this.firstVisualEventSet) { - this.state.update({ firstVisualEvent: msg.time }); + this.activeTabManager.append({ tp: MType.TabChange, tabId: msg.tabId, time: 0 }); + this.state.update({ + firstVisualEvent: msg.time, + currentTab: msg.tabId, + tabs: [msg.tabId], + }); this.firstVisualEventSet = true; } - this.windowNodeCounter.reset(); - this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count); - break; - case MType.CreateTextNode: - case MType.CreateElementNode: - this.windowNodeCounter.addNode(msg.id, msg.parentID); - this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count); - break; - case MType.MoveNode: - this.windowNodeCounter.moveNode(msg.id, msg.parentID); - this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count); - break; - case MType.RemoveNode: - this.windowNodeCounter.removeNode(msg.id); - this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count); - break; } - this.performanceTrackManager.addNodeCountPointIfNeed(msg.time) - isDOMType(msg.tp) && this.pagesManager.appendMessage(msg) + this.tabs[msg.tabId].distributeMessage(msg); break; } - } + }; setMessagesLoading = (messagesLoading: boolean) => { + if (!messagesLoading) { + this.updateChangeEvents(); + } this.screen.display(!messagesLoading); this.state.update({ messagesLoading, ready: !messagesLoading && !this.state.get().cssLoading }); - } + }; decodeMessage(msg: Message) { - return this.decoder.decode(msg) + return this.tabs[this.activeTab].decodeMessage(msg); } - private setSize({ height, width }: { height: number, width: number }) { + private setSize({ height, width }: { height: number; width: number }) { this.screen.scale({ height, width }); this.state.update({ width, height }); } @@ -438,8 +310,15 @@ export default class MessageManager { // TODO: clean managers? clean() { this.state.update(MessageManager.INITIAL_STATE); - // @ts-ignore - this.pagesManager.reset(); } - +} + +function mapTabs(tabs: Record) { + const tabIds = Object.keys(tabs); + const tabMap = {}; + tabIds.forEach((tabId) => { + tabMap[tabId] = `Tab ${tabIds.indexOf(tabId)+1}`; + }); + + return tabMap; } diff --git a/frontend/app/player/web/TabManager.ts b/frontend/app/player/web/TabManager.ts new file mode 100644 index 000000000..512b73a37 --- /dev/null +++ b/frontend/app/player/web/TabManager.ts @@ -0,0 +1,327 @@ +import ListWalker from "Player/common/ListWalker"; +import { + ConnectionInformation, + Message, MType, ResourceTiming, + SetPageLocation, + SetViewportScroll, + SetViewportSize +} from "Player/web/messages"; +import PerformanceTrackManager from "Player/web/managers/PerformanceTrackManager"; +import WindowNodeCounter from "Player/web/managers/WindowNodeCounter"; +import PagesManager from "Player/web/managers/PagesManager"; +// @ts-ignore +import { Decoder } from "syncod"; +import Lists, { InitialLists, INITIAL_STATE as LISTS_INITIAL_STATE, State as ListsState } from "Player/web/Lists"; +import type { Store } from 'Player'; +import Screen from "Player/web/Screen/Screen"; +import { TYPES as EVENT_TYPES } from "Types/session/event"; +import type { PerformanceChartPoint } from './managers/PerformanceTrackManager'; +import { getResourceFromNetworkRequest, getResourceFromResourceTiming, Log, ResourceType } from "Player"; +import { isDOMType } from "Player/web/messages/filters.gen"; + +export interface TabState extends ListsState { + performanceAvailability?: PerformanceTrackManager['availability'] + performanceChartData: PerformanceChartPoint[], + performanceChartTime: PerformanceChartPoint[] + cssLoading: boolean + location: string +} + +/** + * DO NOT DELETE UNUSED METHODS + * THEY'RE ALL USED IN MESSAGE MANAGER VIA this.tabs[id] + * */ + +export default class TabSessionManager { + static INITIAL_STATE: TabState = { + ...LISTS_INITIAL_STATE, + performanceChartData: [], + performanceChartTime: [], + cssLoading: false, + location: '', + } + + private locationEventManager: ListWalker/**/ = new ListWalker(); + private locationManager: ListWalker = new ListWalker(); + private loadedLocationManager: ListWalker = new ListWalker(); + private connectionInfoManger: ListWalker = new ListWalker(); + private performanceTrackManager: PerformanceTrackManager = new PerformanceTrackManager(); + private windowNodeCounter: WindowNodeCounter = new WindowNodeCounter(); + + private resizeManager: ListWalker = new ListWalker([]); + private pagesManager: PagesManager; + private scrollManager: ListWalker = new ListWalker(); + + public readonly decoder = new Decoder(); + private lists: Lists; + private navigationStartOffset = 0 + + constructor( + private readonly session: any, + private readonly state: Store<{ tabStates: { [tabId: string]: TabState } }>, + private readonly screen: Screen, + private readonly id: string, + private readonly setSize: ({ height, width }: { height: number, width: number }) => void, + private readonly sessionStart: number, + initialLists?: Partial, + ) { + this.pagesManager = new PagesManager(screen, this.session.isMobile, this.setCSSLoading) + this.lists = new Lists(initialLists) + initialLists?.event?.forEach((e: Record) => { // TODO: to one of "Movable" module + if (e.type === EVENT_TYPES.LOCATION) { + this.locationEventManager.append(e); + } + }) + } + + public updateLists(lists: Partial) { + Object.keys(lists).forEach((key: 'event' | 'stack' | 'exceptions') => { + const currentList = this.lists.lists[key] + lists[key]!.forEach(item => currentList.insert(item)) + }) + lists?.event?.forEach((e: Record) => { + if (e.type === EVENT_TYPES.LOCATION) { + this.locationEventManager.append(e); + } + }) + + this.updateLocalState({ ...this.lists.getFullListsState() }); + } + + updateLocalState(state: Partial) { + this.state.update({ + tabStates: { + ...this.state.get().tabStates, + [this.id]: { + ...this.state.get().tabStates[this.id], + ...state + } + } + }) + } + + private setCSSLoading = (cssLoading: boolean) => { + this.screen.displayFrame(!cssLoading) + this.updateLocalState({ + cssLoading + }) + this.state.update({ + // @ts-ignore + ready: !this.state.get().messagesLoading && !cssLoading + }) + } + + public resetMessageManagers() { + this.locationEventManager = new ListWalker(); + this.locationManager = new ListWalker(); + this.loadedLocationManager = new ListWalker(); + this.connectionInfoManger = new ListWalker(); + this.scrollManager = new ListWalker(); + this.resizeManager = new ListWalker(); + + this.performanceTrackManager = new PerformanceTrackManager() + this.windowNodeCounter = new WindowNodeCounter(); + this.pagesManager = new PagesManager(this.screen, this.session.isMobile, this.setCSSLoading) + } + + + distributeMessage(msg: Message): void { + switch (msg.tp) { + case MType.SetPageLocation: + this.locationManager.append(msg); + if (msg.navigationStart > 0) { + this.loadedLocationManager.append(msg); + } + break; + case MType.SetViewportSize: + this.resizeManager.append(msg); + break; + case MType.SetViewportScroll: + this.scrollManager.append(msg); + break; + case MType.PerformanceTrack: + this.performanceTrackManager.append(msg); + break; + case MType.SetPageVisibility: + this.performanceTrackManager.handleVisibility(msg) + break; + case MType.ConnectionInformation: + this.connectionInfoManger.append(msg); + break; + case MType.OTable: + this.decoder.set(msg.key, msg.value); + break; + /* Lists: */ + case MType.ConsoleLog: + if (msg.level === 'debug') break; + this.lists.lists.log.append( + // @ts-ignore : TODO: enums in the message schema + Log(msg) + ) + break; + case MType.ResourceTimingDeprecated: + case MType.ResourceTiming: + // TODO: merge `resource` and `fetch` lists into one here instead of UI + if (msg.initiator !== ResourceType.FETCH && msg.initiator !== ResourceType.XHR) { + this.lists.lists.resource.insert(getResourceFromResourceTiming(msg as ResourceTiming, this.sessionStart)) + } + break; + case MType.Fetch: + case MType.NetworkRequest: + this.lists.lists.fetch.insert(getResourceFromNetworkRequest(msg, this.sessionStart)) + break; + case MType.Redux: + this.lists.lists.redux.append(msg); + break; + case MType.NgRx: + this.lists.lists.ngrx.append(msg); + break; + case MType.Vuex: + this.lists.lists.vuex.append(msg); + break; + case MType.Zustand: + this.lists.lists.zustand.append(msg) + break + case MType.MobX: + this.lists.lists.mobx.append(msg); + break; + case MType.GraphQl: + this.lists.lists.graphql.append(msg); + break; + case MType.Profiler: + this.lists.lists.profiles.append(msg); + break; + /* ===|=== */ + default: + switch (msg.tp) { + case MType.CreateDocument: + this.windowNodeCounter.reset(); + this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count); + break; + case MType.CreateTextNode: + case MType.CreateElementNode: + this.windowNodeCounter.addNode(msg.id, msg.parentID); + this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count); + break; + case MType.MoveNode: + this.windowNodeCounter.moveNode(msg.id, msg.parentID); + this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count); + break; + case MType.RemoveNode: + this.windowNodeCounter.removeNode(msg.id); + this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count); + break; + } + this.performanceTrackManager.addNodeCountPointIfNeed(msg.time) + isDOMType(msg.tp) && this.pagesManager.appendMessage(msg) + break; + } + } + + move(t: number, index?: number): void { + const stateToUpdate: Record = {}; + /* == REFACTOR_ME == */ + const lastLoadedLocationMsg = this.loadedLocationManager.moveGetLast(t, index); + if (!!lastLoadedLocationMsg) { + // TODO: page-wise resources list // setListsStartTime(lastLoadedLocationMsg.time) + this.navigationStartOffset = lastLoadedLocationMsg.navigationStart - this.sessionStart; + } + const llEvent = this.locationEventManager.moveGetLast(t, index); + if (!!llEvent) { + if (llEvent.domContentLoadedTime != null) { + stateToUpdate.domContentLoadedTime = { + time: llEvent.domContentLoadedTime + this.navigationStartOffset, //TODO: predefined list of load event for the network tab (merge events & SetPageLocation: add navigationStart to db) + value: llEvent.domContentLoadedTime, + } + } + if (llEvent.loadTime != null) { + stateToUpdate.loadTime = { + time: llEvent.loadTime + this.navigationStartOffset, + value: llEvent.loadTime, + } + } + if (llEvent.domBuildingTime != null) { + stateToUpdate.domBuildingTime = llEvent.domBuildingTime; + } + } + /* === */ + const lastLocationMsg = this.locationManager.moveGetLast(t, index); + if (!!lastLocationMsg) { + stateToUpdate.location = lastLocationMsg.url; + } + const lastConnectionInfoMsg = this.connectionInfoManger.moveGetLast(t, index); + if (!!lastConnectionInfoMsg) { + stateToUpdate.connType = lastConnectionInfoMsg.type; + stateToUpdate.connBandwidth = lastConnectionInfoMsg.downlink; + } + const lastPerformanceTrackMessage = this.performanceTrackManager.moveGetLast(t, index); + if (!!lastPerformanceTrackMessage) { + stateToUpdate.performanceChartTime = lastPerformanceTrackMessage.time; + } + + Object.assign(stateToUpdate, this.lists.moveGetState(t)) + Object.keys(stateToUpdate).length > 0 && this.updateLocalState(stateToUpdate); + + /* Sequence of the managers is important here */ + // Preparing the size of "screen" + const lastResize = this.resizeManager.moveGetLast(t, index); + if (!!lastResize) { + this.setSize(lastResize) + } + this.pagesManager.moveReady(t).then(() => { + const lastScroll = this.scrollManager.moveGetLast(t, index); + if (!!lastScroll && this.screen.window) { + this.screen.window.scrollTo(lastScroll.x, lastScroll.y); + } + }) + } + + public decodeMessage(msg: Message) { + return this.decoder.decode(msg) + } + + public _sortMessagesHack = (msgs: Message[]) => { + // @ts-ignore Hack for upet (TODO: fix ordering in one mutation in tracker(removes first)) + const headChildrenIds = msgs.filter(m => m.parentID === 1).map(m => m.id); + this.pagesManager.sortPages((m1, m2) => { + if (m1.time === m2.time) { + if (m1.tp === MType.RemoveNode && m2.tp !== MType.RemoveNode) { + if (headChildrenIds.includes(m1.id)) { + return -1; + } + } else if (m2.tp === MType.RemoveNode && m1.tp !== MType.RemoveNode) { + if (headChildrenIds.includes(m2.id)) { + return 1; + } + } else if (m2.tp === MType.RemoveNode && m1.tp === MType.RemoveNode) { + const m1FromHead = headChildrenIds.includes(m1.id); + const m2FromHead = headChildrenIds.includes(m2.id); + if (m1FromHead && !m2FromHead) { + return -1; + } else if (m2FromHead && !m1FromHead) { + return 1; + } + } + } + return 0; + }) + } + + public onFileReadSuccess = () => { + const stateToUpdate : Partial> = { + performanceChartData: this.performanceTrackManager.chartData, + performanceAvailability: this.performanceTrackManager.availability, + ...this.lists.getFullListsState(), + } + + this.updateLocalState(stateToUpdate) + } + + public getListsFullState = () => { + return this.lists.getFullListsState() + } + + clean() { + this.pagesManager.reset() + } +} \ No newline at end of file diff --git a/frontend/app/player/web/WebPlayer.ts b/frontend/app/player/web/WebPlayer.ts index 407a69068..fb1c604b6 100644 --- a/frontend/app/player/web/WebPlayer.ts +++ b/frontend/app/player/web/WebPlayer.ts @@ -53,7 +53,8 @@ export default class WebPlayer extends Player { session, wpState, messageManager, - isClickMap + isClickMap, + uiErrorHandler ) super(wpState, messageManager) this.screen = screen @@ -82,7 +83,7 @@ export default class WebPlayer extends Player { } updateLists = (session: any) => { - let lists = { + const lists = { event: session.events || [], frustrations: session.frustrations || [], stack: session.stackEvents || [], @@ -162,6 +163,10 @@ export default class WebPlayer extends Player { this.screen.cursor.showTag(name) } + changeTab = (tab: string) => { + this.messageManager.changeTab(tab) + } + clean = () => { super.clean() this.screen.clean() diff --git a/frontend/app/player/web/assist/AssistManager.ts b/frontend/app/player/web/assist/AssistManager.ts index 254fe2833..3c5436283 100644 --- a/frontend/app/player/web/assist/AssistManager.ts +++ b/frontend/app/player/web/assist/AssistManager.ts @@ -166,9 +166,10 @@ export default class AssistManager { waitingForMessages = true this.setStatus(ConnectionStatus.WaitingMessages) // TODO: reconnect happens frequently on bad network }) - socket.on('messages', messages => { - jmr.append(messages) // as RawMessage[] + let currentTab = '' + socket.on('messages', messages => { + jmr.append(messages.data) // as RawMessage[] if (waitingForMessages) { waitingForMessages = false // TODO: more explicit this.setStatus(ConnectionStatus.Connected) @@ -185,9 +186,15 @@ export default class AssistManager { this.setStatus(ConnectionStatus.Connected) }) - socket.on('UPDATE_SESSION', ({ active }) => { + socket.on('UPDATE_SESSION', (evData) => { + const { metadata = {}, data = {} } = evData + const { tabId } = metadata + const { active } = data this.clearDisconnectTimeout() !this.inactiveTimeout && this.setStatus(ConnectionStatus.Connected) + if (tabId !== currentTab) { + this.store.update({ currentTab: tabId }) + } if (typeof active === "boolean") { this.clearInactiveTimeout() if (active) { diff --git a/frontend/app/player/web/assist/Call.ts b/frontend/app/player/web/assist/Call.ts index 398776e3f..f58c85ea7 100644 --- a/frontend/app/player/web/assist/Call.ts +++ b/frontend/app/player/web/assist/Call.ts @@ -18,6 +18,7 @@ export enum CallingState { export interface State { calling: CallingState; + currentTab?: string; } export default class Call { @@ -158,7 +159,7 @@ export default class Call { } initiateCallEnd = async () => { - this.socket?.emit("call_end", appStore.getState().getIn([ 'user', 'account', 'name'])) + this.emitData("call_end", appStore.getState().getIn([ 'user', 'account', 'name'])) this.handleCallEnd() // TODO: We have it separated, right? (check) // const remoteControl = this.store.get().remoteControl @@ -168,6 +169,10 @@ export default class Call { // } } + private emitData = (event: string, data?: any) => { + this.socket?.emit(event, { meta: { tabId: this.store.get().currentTab }, data }) + } + private callArgs: { localStream: LocalStream, @@ -206,7 +211,7 @@ export default class Call { toggleVideoLocalStream(enabled: boolean) { this.getPeer().then((peer) => { - this.socket.emit('videofeed', { streamId: peer.id, enabled }) + this.emitData('videofeed', { streamId: peer.id, enabled }) }) } @@ -223,7 +228,7 @@ export default class Call { if (![CallingState.NoCall, CallingState.Reconnecting].includes(this.store.get().calling)) { return } this.store.update({ calling: CallingState.Connecting }) this._peerConnection(this.peerID); - this.socket.emit("_agent_name", appStore.getState().getIn([ 'user', 'account', 'name'])) + this.emitData("_agent_name", appStore.getState().getIn([ 'user', 'account', 'name'])) } private async _peerConnection(remotePeerId: string) { diff --git a/frontend/app/player/web/assist/RemoteControl.ts b/frontend/app/player/web/assist/RemoteControl.ts index 56870995d..12a11c19c 100644 --- a/frontend/app/player/web/assist/RemoteControl.ts +++ b/frontend/app/player/web/assist/RemoteControl.ts @@ -12,6 +12,7 @@ export enum RemoteControlStatus { export interface State { annotating: boolean remoteControl: RemoteControlStatus + currentTab?: string } export default class RemoteControl { @@ -28,11 +29,11 @@ export default class RemoteControl { private agentInfo: Object, private onToggle: (active: boolean) => void, ){ - socket.on("control_granted", id => { - this.toggleRemoteControl(id === socket.id) + socket.on("control_granted", ({ meta, data }) => { + this.toggleRemoteControl(data === socket.id) }) - socket.on("control_rejected", id => { - id === socket.id && this.toggleRemoteControl(false) + socket.on("control_rejected", ({ meta, data }) => { + data === socket.id && this.toggleRemoteControl(false) this.onReject() }) socket.on('SESSION_DISCONNECTED', () => { @@ -50,14 +51,18 @@ export default class RemoteControl { private onMouseMove = (e: MouseEvent): void => { const data = this.screen.getInternalCoordinates(e) - this.socket.emit("move", [ data.x, data.y ]) + this.emitData("move", [ data.x, data.y ]) + } + + private emitData = (event: string, data?: any) => { + this.socket.emit(event, { meta: { tabId: this.store.get().currentTab }, data }) } private onWheel = (e: WheelEvent): void => { e.preventDefault() //throttling makes movements less smooth, so it is omitted //this.onMouseMove(e) - this.socket.emit("scroll", [ e.deltaX, e.deltaY ]) + this.emitData("scroll", [ e.deltaX, e.deltaY ]) } public setCallbacks = ({ onReject }: { onReject: () => void }) => { @@ -76,9 +81,9 @@ export default class RemoteControl { if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement ) { - this.socket && this.socket.emit("input", el.value) + this.socket && this.emitData("input", el.value) } else if (el.isContentEditable) { - this.socket && this.socket.emit("input", el.innerText) + this.socket && this.emitData("input", el.innerText) } } // TODO: send "focus" event to assist with the nodeID @@ -92,7 +97,7 @@ export default class RemoteControl { el.onblur = null } } - this.socket.emit("click", [ data.x, data.y ]); + this.emitData("click", [ data.x, data.y ]); } private toggleRemoteControl(enable: boolean){ @@ -116,17 +121,17 @@ export default class RemoteControl { if (remoteControl === RemoteControlStatus.Requesting) { return } if (remoteControl === RemoteControlStatus.Disabled) { this.store.update({ remoteControl: RemoteControlStatus.Requesting }) - this.socket.emit("request_control", JSON.stringify({ - ...this.agentInfo, - query: document.location.search - })) + this.emitData("request_control", JSON.stringify({ + ...this.agentInfo, + query: document.location.search + })) } else { this.releaseRemoteControl() } } releaseRemoteControl = () => { - this.socket.emit("release_control") + this.emitData("release_control",) this.toggleRemoteControl(false) } @@ -134,30 +139,30 @@ export default class RemoteControl { toggleAnnotation(enable?: boolean) { if (typeof enable !== "boolean") { - enable = !!this.store.get().annotating + enable = this.store.get().annotating } if (enable && !this.annot) { const annot = this.annot = new AnnotationCanvas() annot.mount(this.screen.overlay) annot.canvas.addEventListener("mousedown", e => { - const data = this.screen.getInternalViewportCoordinates(e) + const data = this.screen.getInternalViewportCoordin1ates(e) annot.start([ data.x, data.y ]) - this.socket.emit("startAnnotation", [ data.x, data.y ]) + this.emitData("startAnnotation", [ data.x, data.y ]) }) annot.canvas.addEventListener("mouseleave", () => { annot.stop() - this.socket.emit("stopAnnotation") + this.emitData("stopAnnotation") }) annot.canvas.addEventListener("mouseup", () => { annot.stop() - this.socket.emit("stopAnnotation") + this.emitData("stopAnnotation") }) annot.canvas.addEventListener("mousemove", e => { if (!annot.isPainting()) { return } const data = this.screen.getInternalViewportCoordinates(e) annot.move([ data.x, data.y ]) - this.socket.emit("moveAnnotation", [ data.x, data.y ]) + this.emitData("moveAnnotation", [ data.x, data.y ]) }) this.store.update({ annotating: true }) } else if (!enable && !!this.annot) { diff --git a/frontend/app/player/web/assist/ScreenRecording.ts b/frontend/app/player/web/assist/ScreenRecording.ts index 83b06b497..fdbd850a2 100644 --- a/frontend/app/player/web/assist/ScreenRecording.ts +++ b/frontend/app/player/web/assist/ScreenRecording.ts @@ -10,6 +10,7 @@ export enum SessionRecordingStatus { export interface State { recordingState: SessionRecordingStatus; + currentTab?: string; } export default class ScreenRecording { @@ -46,14 +47,19 @@ export default class ScreenRecording { if (recordingState === SessionRecordingStatus.Requesting) return; this.store.update({ recordingState: SessionRecordingStatus.Requesting }) - this.socket.emit("request_recording", JSON.stringify({ - ...this.agentInfo, - query: document.location.search, - })) + this.emitData("request_recording", JSON.stringify({ + ...this.agentInfo, + query: document.location.search, + }) + ) + } + + private emitData = (event: string, data?: any) => { + this.socket.emit(event, { meta: { tabId: this.store.get().currentTab }, data }) } stopRecording = () => { - this.socket.emit("stop_recording") + this.emitData("stop_recording") this.toggleRecording(false) } diff --git a/frontend/app/player/web/managers/ActiveTabManager.ts b/frontend/app/player/web/managers/ActiveTabManager.ts new file mode 100644 index 000000000..e9bc9a960 --- /dev/null +++ b/frontend/app/player/web/managers/ActiveTabManager.ts @@ -0,0 +1,18 @@ +import ListWalker from '../../common/ListWalker'; +import type { TabChange } from '../messages'; + +export default class ActiveTabManager extends ListWalker { + currentTime = 0; + + moveReady(t: number): Promise { + + if (t < this.currentTime) { + this.reset() + } + this.currentTime = t + const msg = this.moveGetLastDebug(t) + // console.log('move', t, msg, this.list) + + return Promise.resolve(msg?.tabId || null) + } +} \ No newline at end of file diff --git a/frontend/app/player/web/messages/MFileReader.ts b/frontend/app/player/web/messages/MFileReader.ts index 732ca74be..e1ce5969b 100644 --- a/frontend/app/player/web/messages/MFileReader.ts +++ b/frontend/app/player/web/messages/MFileReader.ts @@ -22,6 +22,7 @@ export default class MFileReader extends RawMessageReader { // 0xff 0xff 0xff 0xff 0xff 0xff 0xff 0xff = no indexes + weird failover (don't ask) const skipIndexes = this.readCustomIndex(this.buf.slice(0, 8)) === 72057594037927940 || this.readCustomIndex(this.buf.slice(0, 9)) === 72057594037927940 + if (skipIndexes) { this.noIndexes = true this.skip(8) @@ -63,6 +64,7 @@ export default class MFileReader extends RawMessageReader { } } + currentTab = 'back-compatability' readNext(): Message & { _index?: number } | null { if (this.error || !this.hasNextByte()) { return null @@ -82,6 +84,10 @@ export default class MFileReader extends RawMessageReader { return null } + if (rMsg.tp === MType.TabData) { + this.currentTab = rMsg.tabId + return this.readNext() + } if (rMsg.tp === MType.Timestamp) { if (!this.startTime) { this.startTime = rMsg.timestamp @@ -93,6 +99,7 @@ export default class MFileReader extends RawMessageReader { const index = this.noIndexes ? 0 : this.getLastMessageID() const msg = Object.assign(rewriteMessage(rMsg), { time: this.currentTime, + tabId: this.currentTab, }, !this.noIndexes ? { _index: index } : {}) return msg diff --git a/frontend/app/player/web/messages/MStreamReader.ts b/frontend/app/player/web/messages/MStreamReader.ts index a61e374cd..1954c53e5 100644 --- a/frontend/app/player/web/messages/MStreamReader.ts +++ b/frontend/app/player/web/messages/MStreamReader.ts @@ -11,18 +11,28 @@ export default class MStreamReader { private t: number = 0 private idx: number = 0 + + currentTab = 'back-compatability' readNext(): Message & { _index: number } | null { let msg = this.r.readMessage() if (msg === null) { return null } if (msg.tp === MType.Timestamp) { this.startTs = this.startTs || msg.timestamp - this.t = msg.timestamp - this.startTs + const newT = msg.timestamp - this.startTs + if (newT > this.t) { + this.t = newT + } + return this.readNext() + } + if (msg.tp === MType.TabData) { + this.currentTab = msg.tabId return this.readNext() } return Object.assign(msg, { time: this.t, _index: this.idx++, + tabId: this.currentTab, }) } } diff --git a/frontend/app/player/web/messages/RawMessageReader.gen.ts b/frontend/app/player/web/messages/RawMessageReader.gen.ts index 135960cef..78a9254dc 100644 --- a/frontend/app/player/web/messages/RawMessageReader.gen.ts +++ b/frontend/app/player/web/messages/RawMessageReader.gen.ts @@ -673,6 +673,22 @@ export default class RawMessageReader extends PrimitiveReader { }; } + case 117: { + const tabId = this.readString(); if (tabId === null) { return resetPointer() } + return { + tp: MType.TabChange, + tabId, + }; + } + + case 118: { + const tabId = this.readString(); if (tabId === null) { return resetPointer() } + return { + tp: MType.TabData, + tabId, + }; + } + case 90: { const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() } const projectID = this.readUint(); if (projectID === null) { return resetPointer() } diff --git a/frontend/app/player/web/messages/filters.gen.ts b/frontend/app/player/web/messages/filters.gen.ts index 2cd1b6c25..6eedfd1d2 100644 --- a/frontend/app/player/web/messages/filters.gen.ts +++ b/frontend/app/player/web/messages/filters.gen.ts @@ -3,7 +3,7 @@ import { MType } from './raw.gen' -const DOM_TYPES = [0,4,5,6,7,8,9,10,11,12,13,14,15,16,18,19,20,37,38,49,50,51,54,55,57,58,59,60,61,67,69,70,71,72,73,74,75,76,77,113,114,90,93,96,100,102,103,105] +const DOM_TYPES = [0,4,5,6,7,8,9,10,11,12,13,14,15,16,18,19,20,37,38,49,50,51,54,55,57,58,59,60,61,67,69,70,71,72,73,74,75,76,77,113,114,117,118,90,93,96,100,102,103,105] export function isDOMType(t: MType) { return DOM_TYPES.includes(t) } \ No newline at end of file diff --git a/frontend/app/player/web/messages/message.gen.ts b/frontend/app/player/web/messages/message.gen.ts index 6a8916cd4..fd622f0c5 100644 --- a/frontend/app/player/web/messages/message.gen.ts +++ b/frontend/app/player/web/messages/message.gen.ts @@ -58,6 +58,8 @@ import type { RawSelectionChange, RawMouseThrashing, RawResourceTiming, + RawTabChange, + RawTabData, RawIosSessionStart, RawIosCustomEvent, RawIosScreenChanges, @@ -178,6 +180,10 @@ export type MouseThrashing = RawMouseThrashing & Timed export type ResourceTiming = RawResourceTiming & Timed +export type TabChange = RawTabChange & Timed + +export type TabData = RawTabData & Timed + export type IosSessionStart = RawIosSessionStart & Timed export type IosCustomEvent = RawIosCustomEvent & Timed diff --git a/frontend/app/player/web/messages/raw.gen.ts b/frontend/app/player/web/messages/raw.gen.ts index ecd88631c..8c37ebada 100644 --- a/frontend/app/player/web/messages/raw.gen.ts +++ b/frontend/app/player/web/messages/raw.gen.ts @@ -56,6 +56,8 @@ export const enum MType { SelectionChange = 113, MouseThrashing = 114, ResourceTiming = 116, + TabChange = 117, + TabData = 118, IosSessionStart = 90, IosCustomEvent = 93, IosScreenChanges = 96, @@ -447,6 +449,16 @@ export interface RawResourceTiming { cached: boolean, } +export interface RawTabChange { + tp: MType.TabChange, + tabId: string, +} + +export interface RawTabData { + tp: MType.TabData, + tabId: string, +} + export interface RawIosSessionStart { tp: MType.IosSessionStart, timestamp: number, @@ -518,4 +530,4 @@ export interface RawIosNetworkCall { } -export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequest | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTimingDeprecated | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawSelectionChange | RawMouseThrashing | RawResourceTiming | RawIosSessionStart | RawIosCustomEvent | RawIosScreenChanges | RawIosClickEvent | RawIosPerformanceEvent | RawIosLog | RawIosNetworkCall; +export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequest | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTimingDeprecated | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawSelectionChange | RawMouseThrashing | RawResourceTiming | RawTabChange | RawTabData | RawIosSessionStart | RawIosCustomEvent | RawIosScreenChanges | RawIosClickEvent | RawIosPerformanceEvent | RawIosLog | RawIosNetworkCall; diff --git a/frontend/app/player/web/messages/tracker-legacy.gen.ts b/frontend/app/player/web/messages/tracker-legacy.gen.ts index 7215b8c3c..207b4ce2a 100644 --- a/frontend/app/player/web/messages/tracker-legacy.gen.ts +++ b/frontend/app/player/web/messages/tracker-legacy.gen.ts @@ -57,6 +57,8 @@ export const TP_MAP = { 113: MType.SelectionChange, 114: MType.MouseThrashing, 116: MType.ResourceTiming, + 117: MType.TabChange, + 118: MType.TabData, 90: MType.IosSessionStart, 93: MType.IosCustomEvent, 96: MType.IosScreenChanges, diff --git a/frontend/app/player/web/messages/tracker.gen.ts b/frontend/app/player/web/messages/tracker.gen.ts index a8f9a7f14..245e93435 100644 --- a/frontend/app/player/web/messages/tracker.gen.ts +++ b/frontend/app/player/web/messages/tracker.gen.ts @@ -470,8 +470,18 @@ type TrResourceTiming = [ cached: boolean, ] +type TrTabChange = [ + type: 117, + tabId: string, +] -export type TrackerMessage = TrTimestamp | TrSetPageLocation | TrSetViewportSize | TrSetViewportScroll | TrCreateDocument | TrCreateElementNode | TrCreateTextNode | TrMoveNode | TrRemoveNode | TrSetNodeAttribute | TrRemoveNodeAttribute | TrSetNodeData | TrSetNodeScroll | TrSetInputTarget | TrSetInputValue | TrSetInputChecked | TrMouseMove | TrNetworkRequest | TrConsoleLog | TrPageLoadTiming | TrPageRenderTiming | TrCustomEvent | TrUserID | TrUserAnonymousID | TrMetadata | TrCSSInsertRule | TrCSSDeleteRule | TrFetch | TrProfiler | TrOTable | TrStateAction | TrRedux | TrVuex | TrMobX | TrNgRx | TrGraphQL | TrPerformanceTrack | TrStringDict | TrSetNodeAttributeDict | TrResourceTimingDeprecated | TrConnectionInformation | TrSetPageVisibility | TrLoadFontFace | TrSetNodeFocus | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner | TrJSException | TrZustand | TrBatchMetadata | TrPartitionedMessage | TrInputChange | TrSelectionChange | TrMouseThrashing | TrUnbindNodes | TrResourceTiming +type TrTabData = [ + type: 118, + tabId: string, +] + + +export type TrackerMessage = TrTimestamp | TrSetPageLocation | TrSetViewportSize | TrSetViewportScroll | TrCreateDocument | TrCreateElementNode | TrCreateTextNode | TrMoveNode | TrRemoveNode | TrSetNodeAttribute | TrRemoveNodeAttribute | TrSetNodeData | TrSetNodeScroll | TrSetInputTarget | TrSetInputValue | TrSetInputChecked | TrMouseMove | TrNetworkRequest | TrConsoleLog | TrPageLoadTiming | TrPageRenderTiming | TrCustomEvent | TrUserID | TrUserAnonymousID | TrMetadata | TrCSSInsertRule | TrCSSDeleteRule | TrFetch | TrProfiler | TrOTable | TrStateAction | TrRedux | TrVuex | TrMobX | TrNgRx | TrGraphQL | TrPerformanceTrack | TrStringDict | TrSetNodeAttributeDict | TrResourceTimingDeprecated | TrConnectionInformation | TrSetPageVisibility | TrLoadFontFace | TrSetNodeFocus | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner | TrJSException | TrZustand | TrBatchMetadata | TrPartitionedMessage | TrInputChange | TrSelectionChange | TrMouseThrashing | TrUnbindNodes | TrResourceTiming | TrTabChange | TrTabData export default function translate(tMsg: TrackerMessage): RawMessage | null { switch(tMsg[0]) { @@ -940,6 +950,20 @@ export default function translate(tMsg: TrackerMessage): RawMessage | null { } } + case 117: { + return { + tp: MType.TabChange, + tabId: tMsg[1], + } + } + + case 118: { + return { + tp: MType.TabData, + tabId: tMsg[1], + } + } + default: return null } diff --git a/frontend/app/player/web/storageSelectors.ts b/frontend/app/player/web/storageSelectors.ts index 1fa1ea32b..0ceac8637 100644 --- a/frontend/app/player/web/storageSelectors.ts +++ b/frontend/app/player/web/storageSelectors.ts @@ -12,16 +12,15 @@ export enum StorageType { export const STORAGE_TYPES = StorageType // TODO: update name everywhere export function selectStorageType(state: State): StorageType { - if (!state.reduxList) return StorageType.NONE - if (state.reduxList.length > 0) { + if (state.reduxList?.length > 0) { return StorageType.REDUX - } else if (state.vuexList.length > 0) { + } else if (state.vuexList?.length > 0) { return StorageType.VUEX - } else if (state.mobxList.length > 0) { + } else if (state.mobxList?.length > 0) { return StorageType.MOBX - } else if (state.ngrxList.length > 0) { + } else if (state.ngrxList?.length > 0) { return StorageType.NGRX - } else if (state.zustandList.length > 0) { + } else if (state.zustandList?.length > 0) { return StorageType.ZUSTAND } return StorageType.NONE diff --git a/frontend/app/player/web/types/log.ts b/frontend/app/player/web/types/log.ts index 22a20d33c..645f0db99 100644 --- a/frontend/app/player/web/types/log.ts +++ b/frontend/app/player/web/types/log.ts @@ -13,6 +13,7 @@ export interface ILog { time: number index?: number errorId?: string + tabId?: string } export const Log = (log: ILog) => ({ diff --git a/frontend/app/types/session/event.ts b/frontend/app/types/session/event.ts index edb63cda7..a20bcc768 100644 --- a/frontend/app/types/session/event.ts +++ b/frontend/app/types/session/event.ts @@ -23,6 +23,7 @@ interface IEvent { key: number; label: string; targetPath: string; + tabId?: string; target: { path: string; label: string; @@ -69,12 +70,14 @@ class Event { time: IEvent['time']; label: IEvent['label']; target: IEvent['target']; + tabId: IEvent['tabId']; constructor(event: IEvent) { Object.assign(this, { time: event.time, label: event.label, key: event.key, + tabId: event.tabId, target: { path: event.target?.path || event.targetPath, label: event.target?.label, diff --git a/frontend/app/types/session/index.js b/frontend/app/types/session/index.js index 976623abf..57b0fc61f 100644 --- a/frontend/app/types/session/index.js +++ b/frontend/app/types/session/index.js @@ -1 +1 @@ -export { default } from './session'; \ No newline at end of file +export { default, mergeEventLists } from './session'; \ No newline at end of file diff --git a/frontend/app/types/session/session.ts b/frontend/app/types/session/session.ts index f8d8c6dad..ad0bfd599 100644 --- a/frontend/app/types/session/session.ts +++ b/frontend/app/types/session/session.ts @@ -9,7 +9,7 @@ import { toJS } from 'mobx'; const HASH_MOD = 1610612741; const HASH_P = 53; -function mergeEventLists, Y extends Record>(arr1: T[], arr2: Y[]): Array { +export function mergeEventLists, Y extends Record>(arr1: T[], arr2: Y[]): Array { let merged = []; let index1 = 0; let index2 = 0; diff --git a/frontend/cypress/e2e/replayer.cy.ts b/frontend/cypress/e2e/replayer.cy.ts index 4a01ab07a..e3bde6f80 100644 --- a/frontend/cypress/e2e/replayer.cy.ts +++ b/frontend/cypress/e2e/replayer.cy.ts @@ -34,7 +34,7 @@ describe( cy.get('#redcounter').click().click().click(); cy.get('#test-api').click().click(); cy.get('#test-event').click().click(); - cy.wait(SECOND * 3); + cy.wait(SECOND * 15); cy.log('finished generating a session') diff --git a/mobs/messages.rb b/mobs/messages.rb index 5ac7b6ff2..c571107de 100644 --- a/mobs/messages.rb +++ b/mobs/messages.rb @@ -484,6 +484,14 @@ message 116, 'ResourceTiming', :replayer => :devtools do boolean 'Cached' end +message 117, 'TabChange' do + string 'TabId' +end + +message 118, 'TabData' do + string 'TabId' +end + ## Backend-only message 125, 'IssueEvent', :replayer => false, :tracker => false do uint 'MessageID' diff --git a/tracker/tracker-assist/.gitignore b/tracker/tracker-assist/.gitignore index b5c5ddbce..2e68528fc 100644 --- a/tracker/tracker-assist/.gitignore +++ b/tracker/tracker-assist/.gitignore @@ -6,3 +6,4 @@ cjs .cache *.cache *.DS_Store +coverage \ No newline at end of file diff --git a/tracker/tracker-assist/CHANGELOG.md b/tracker/tracker-assist/CHANGELOG.md index 5df2617c9..c25146776 100644 --- a/tracker/tracker-assist/CHANGELOG.md +++ b/tracker/tracker-assist/CHANGELOG.md @@ -1,3 +1,7 @@ +## 6.0.0 + +- added support for multi tab assist session + ## 5.0.2 - Added `onCallDeny`, `onRemoteControlDeny` and `onRecordingDeny` callbacks to signal denial of user's consent to call/control/recording diff --git a/tracker/tracker-assist/jest.config.js b/tracker/tracker-assist/jest.config.js new file mode 100644 index 000000000..62b94c152 --- /dev/null +++ b/tracker/tracker-assist/jest.config.js @@ -0,0 +1,13 @@ +/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ +const config = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + collectCoverage: true, + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts',], + // .js file extension fix + moduleNameMapper: { + '(.+)\\.js': '$1', + }, +} + +export default config diff --git a/tracker/tracker-assist/package.json b/tracker/tracker-assist/package.json index 1bda6b785..3c9961a7e 100644 --- a/tracker/tracker-assist/package.json +++ b/tracker/tracker-assist/package.json @@ -1,7 +1,7 @@ { "name": "@openreplay/tracker-assist", "description": "Tracker plugin for screen assistance through the WebRTC", - "version": "5.0.2", + "version": "6.0.0-beta.1", "keywords": [ "WebRTC", "assistance", @@ -13,6 +13,7 @@ "type": "module", "main": "./lib/index.js", "scripts": { + "tsrun": "tsc", "lint": "eslint src --ext .ts,.js --fix --quiet", "build": "npm run build-es && npm run build-cjs", "build-es": "rm -Rf lib && tsc && npm run replace-versions", @@ -23,7 +24,10 @@ "replace-req-version": "replace-in-files lib/* cjs/* --string='REQUIRED_TRACKER_VERSION' --replacement='3.5.14'", "prepublishOnly": "npm run build", "prepare": "cd ../../ && husky install tracker/.husky/", - "lint-front": "lint-staged" + "lint-front": "lint-staged", + "test": "jest --coverage=false", + "test:ci": "jest --coverage=true", + "postversion": "npm run build" }, "dependencies": { "csstype": "^3.0.10", @@ -31,7 +35,7 @@ "socket.io-client": "^4.4.1" }, "peerDependencies": { - "@openreplay/tracker": ">=5.0.0" + "@openreplay/tracker": ">=8.0.0" }, "devDependencies": { "@openreplay/tracker": "file:../tracker", @@ -41,9 +45,12 @@ "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.2.1", "husky": "^8.0.1", + "jest": "^29.3.1", + "jest-environment-jsdom": "^29.3.1", "lint-staged": "^13.0.3", "prettier": "^2.7.1", "replace-in-files-cli": "^1.0.0", + "ts-jest": "^29.0.3", "typescript": "^4.6.0-dev.20211126" }, "husky": { diff --git a/tracker/tracker-assist/src/Assist.ts b/tracker/tracker-assist/src/Assist.ts index f31f85c22..5c288cae2 100644 --- a/tracker/tracker-assist/src/Assist.ts +++ b/tracker/tracker-assist/src/Assist.ts @@ -127,8 +127,8 @@ export default class Assist { app.session.attachUpdateCallback(sessInfo => this.emit('UPDATE_SESSION', sessInfo)) } - private emit(ev: string, ...args): void { - this.socket && this.socket.emit(ev, ...args) + private emit(ev: string, args?: any): void { + this.socket && this.socket.emit(ev, { meta: { tabId: this.app.getTabId(), }, data: args, }) } private get agentsConnected(): boolean { @@ -164,7 +164,7 @@ export default class Assist { if (!sessionId) { return app.debug.error('No session ID') } - const peerID = `${app.getProjectKey()}-${sessionId}` + const peerID = `${app.getProjectKey()}-${sessionId}-${this.app.getTabId()}` // SocketIO const socket = this.socket = connect(this.getHost(), { @@ -172,6 +172,7 @@ export default class Assist { query: { 'peerId': peerID, 'identity': 'session', + 'tabId': this.app.getTabId(), 'sessionInfo': JSON.stringify({ pageTitle: document.title, active: true, @@ -180,7 +181,12 @@ export default class Assist { }, transports: ['websocket',], }) - socket.onAny((...args) => app.debug.log('Socket:', ...args)) + socket.onAny((...args) => { + if (args[0] === 'messages' || args[0] === 'UPDATE_SESSION') { + return + } + app.debug.log('Socket:', ...args) + }) this.remoteControl = new RemoteControl( this.options, @@ -197,7 +203,11 @@ export default class Assist { annot.mount() return callingAgents.get(id) }, - (id, isDenied) => { + (id, isDenied) => onRelease(id, isDenied), + ) + + const onRelease = (id, isDenied) => { + { if (id) { const cb = this.agents[id].onControlReleased delete this.agents[id].onControlReleased @@ -217,8 +227,8 @@ export default class Assist { const info = id ? this.agents[id]?.agentInfo : {} this.options.onRemoteControlDeny?.(info || {}) } - }, - ) + } + } const onAcceptRecording = () => { socket.emit('recording_accepted') @@ -230,24 +240,37 @@ export default class Assist { } const recordingState = new ScreenRecordingState(this.options.recordingConfirm) - // TODO: check incoming args - socket.on('request_control', this.remoteControl.requestControl) - socket.on('release_control', this.remoteControl.releaseControl) - socket.on('scroll', this.remoteControl.scroll) - socket.on('click', this.remoteControl.click) - socket.on('move', this.remoteControl.move) - socket.on('focus', (clientID, nodeID) => { - const el = app.nodes.getNode(nodeID) - if (el instanceof HTMLElement && this.remoteControl) { - this.remoteControl.focus(clientID, el) + function processEvent(agentId: string, event: { meta: { tabId: string }, data?: any }, callback?: (id: string, data: any) => void) { + if (app.getTabId() === event.meta.tabId) { + return callback?.(agentId, event.data) } - }) - socket.on('input', this.remoteControl.input) + } + if (this.remoteControl !== null) { + socket.on('request_control', (agentId, dataObj) => { + processEvent(agentId, dataObj, this.remoteControl?.requestControl) + }) + socket.on('release_control', (agentId, dataObj) => { + processEvent(agentId, dataObj, (_, data) => + this.remoteControl?.releaseControl(data) + ) + }) + socket.on('scroll', (id, event) => processEvent(id, event, this.remoteControl?.scroll)) + socket.on('click', (id, event) => processEvent(id, event, this.remoteControl?.click)) + socket.on('move', (id, event) => processEvent(id, event, this.remoteControl?.move)) + socket.on('focus', (id, event) => processEvent(id, event, (clientID, nodeID) => { + const el = app.nodes.getNode(nodeID) + if (el instanceof HTMLElement && this.remoteControl) { + this.remoteControl.focus(clientID, el) + } + })) + socket.on('input', (id, event) => processEvent(id, event, this.remoteControl?.input)) + } - socket.on('moveAnnotation', (_, p) => annot && annot.move(p)) // TODO: restrict by id - socket.on('startAnnotation', (_, p) => annot && annot.start(p)) - socket.on('stopAnnotation', () => annot && annot.stop()) + // TODO: restrict by id + socket.on('moveAnnotation', (id, event) => processEvent(id, event, (_, d) => annot && annot.move(d))) + socket.on('startAnnotation', (id, event) => processEvent(id, event, (_, d) => annot?.start(d))) + socket.on('stopAnnotation', (id, event) => processEvent(id, event, annot?.stop)) socket.on('NEW_AGENT', (id: string, info) => { this.agents[id] = { @@ -256,7 +279,10 @@ export default class Assist { } this.assistDemandedRestart = true this.app.stop() - this.app.start().then(() => { this.assistDemandedRestart = false }).catch(e => app.debug.error(e)) + setTimeout(() => { + this.app.start().then(() => { this.assistDemandedRestart = false }).catch(e => app.debug.error(e)) + // TODO: check if it's needed; basically allowing some time for the app to finish everything before starting again + }, 500) }) socket.on('AGENTS_CONNECTED', (ids: string[]) => { ids.forEach(id =>{ @@ -268,7 +294,10 @@ export default class Assist { }) this.assistDemandedRestart = true this.app.stop() - this.app.start().then(() => { this.assistDemandedRestart = false }).catch(e => app.debug.error(e)) + setTimeout(() => { + this.app.start().then(() => { this.assistDemandedRestart = false }).catch(e => app.debug.error(e)) + // TODO: check if it's needed; basically allowing some time for the app to finish everything before starting again + }, 500) this.remoteControl?.reconnect(ids) }) @@ -287,7 +316,8 @@ export default class Assist { this.agents = {} if (recordingState.isActive) recordingState.stopRecording() }) - socket.on('call_end', (id) => { + socket.on('call_end', (info) => { + const id = info.data if (!callingAgents.has(id)) { app.debug.warn('Received call_end from unknown agent', id) return @@ -295,14 +325,20 @@ export default class Assist { endAgentCall(id) }) - socket.on('_agent_name', (id, name) => { + socket.on('_agent_name', (id, info) => { + if (app.getTabId() !== info.meta.tabId) return + const name = info.data callingAgents.set(id, name) updateCallerNames() }) - socket.on('videofeed', (_, feedState) => { + socket.on('videofeed', (_, info) => { + if (app.getTabId() !== info.meta.tabId) return + const feedState = info.data callUI?.toggleVideoStream(feedState) }) - socket.on('request_recording', (id, agentData) => { + socket.on('request_recording', (id, info) => { + if (app.getTabId() !== info.meta.tabId) return + const agentData = info.data if (!recordingState.isActive) { this.options.onRecordingRequest?.(JSON.parse(agentData)) recordingState.requestRecording(id, onAcceptRecording, () => onRejectRecording(agentData)) @@ -310,7 +346,8 @@ export default class Assist { this.emit('recording_busy') } }) - socket.on('stop_recording', (id) => { + socket.on('stop_recording', (id, info) => { + if (app.getTabId() !== info.meta.tabId) return if (recordingState.isActive) { recordingState.stopAgentRecording(id) } @@ -482,6 +519,11 @@ export default class Assist { }) call.answer(lStreams[call.peer].stream) + + document.addEventListener('visibilitychange', () => { + initiateCallEnd() + }) + this.setCallingState(CallingState.True) if (!callEndCallback) { callEndCallback = this.options.onCallStart?.() } diff --git a/tracker/tracker-assist/src/RemoteControl.ts b/tracker/tracker-assist/src/RemoteControl.ts index c56ed3222..2f139c68d 100644 --- a/tracker/tracker-assist/src/RemoteControl.ts +++ b/tracker/tracker-assist/src/RemoteControl.ts @@ -18,8 +18,8 @@ if (nativeInputValueDescriptor && nativeInputValueDescriptor.set) { export default class RemoteControl { - private mouse: Mouse | null - status: RCStatus = RCStatus.Disabled + private mouse: Mouse | null = null + public status: RCStatus = RCStatus.Disabled private agentID: string | null = null constructor( @@ -89,6 +89,9 @@ export default class RemoteControl { } this.mouse = new Mouse(agentName) this.mouse.mount() + document.addEventListener('visibilitychange', () => { + if (document.hidden) this.releaseControl(false) + }) } resetMouse = () => { @@ -97,7 +100,9 @@ export default class RemoteControl { } scroll = (id, d) => { id === this.agentID && this.mouse?.scroll(d) } - move = (id, xy) => { id === this.agentID && this.mouse?.move(xy) } + move = (id, xy) => { + return id === this.agentID && this.mouse?.move(xy) + } private focused: HTMLElement | null = null click = (id, xy) => { if (id !== this.agentID || !this.mouse) { return } @@ -109,7 +114,9 @@ export default class RemoteControl { input = (id, value: string) => { if (id !== this.agentID || !this.mouse || !this.focused) { return } if (this.focused instanceof HTMLTextAreaElement - || this.focused instanceof HTMLInputElement) { + || this.focused instanceof HTMLInputElement + || this.focused.tagName === 'INPUT' + || this.focused.tagName === 'TEXTAREA') { setInputValue.call(this.focused, value) const ev = new Event('input', { bubbles: true,}) this.focused.dispatchEvent(ev) diff --git a/tracker/tracker-assist/tests/AnnotationCanvas.test.ts b/tracker/tracker-assist/tests/AnnotationCanvas.test.ts new file mode 100644 index 000000000..ba09e2840 --- /dev/null +++ b/tracker/tracker-assist/tests/AnnotationCanvas.test.ts @@ -0,0 +1,148 @@ +import AnnotationCanvas from '../src/AnnotationCanvas' +import { describe, expect, test, it, jest, beforeEach, afterEach, } from '@jest/globals' + + +describe('AnnotationCanvas', () => { + let annotationCanvas + let documentBody + let canvasMock + let contextMock + + beforeEach(() => { + canvasMock = { + width: 0, + height: 0, + style: {}, + getContext: jest.fn(() => contextMock as unknown as HTMLCanvasElement), + parentNode: document, + } + + contextMock = { + globalAlpha: 1.0, + beginPath: jest.fn(), + moveTo: jest.fn(), + lineTo: jest.fn(), + lineWidth: 8, + lineCap: 'round', + lineJoin: 'round', + strokeStyle: 'red', + stroke: jest.fn(), + globalCompositeOperation: '', + fillStyle: '', + fillRect: jest.fn(), + clearRect: jest.fn(), + } + + documentBody = document.body + // @ts-ignore + document['removeChild'] = (el) => jest.fn(el) + // @ts-ignore + document['createElement'] = () => canvasMock + + jest.spyOn(documentBody, 'appendChild').mockImplementation(jest.fn()) + jest.spyOn(documentBody, 'removeChild').mockImplementation(jest.fn()) + jest.spyOn(window, 'addEventListener').mockImplementation(jest.fn()) + jest.spyOn(window, 'removeEventListener').mockImplementation(jest.fn()) + annotationCanvas = new AnnotationCanvas() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should create a canvas element with correct styles when initialized', () => { + const createElSpy = jest.spyOn(document, 'createElement') + annotationCanvas = new AnnotationCanvas() + expect(createElSpy).toHaveBeenCalledWith('canvas') + expect(canvasMock.style.position).toBe('fixed') + expect(canvasMock.style.left).toBe(0) + expect(canvasMock.style.top).toBe(0) + expect(canvasMock.style.pointerEvents).toBe('none') + expect(canvasMock.style.zIndex).toBe(2147483647 - 2) + }) + + it('should resize the canvas when calling resizeCanvas method', () => { + annotationCanvas.resizeCanvas() + + expect(canvasMock.width).toBe(window.innerWidth) + expect(canvasMock.height).toBe(window.innerHeight) + }) + + it('should start painting and set the last position when calling start method', () => { + const position = [10, 20,] + + annotationCanvas.start(position) + + expect(annotationCanvas.painting).toBe(true) + expect(annotationCanvas.clrTmID).toBeNull() + expect(annotationCanvas.lastPosition).toEqual(position) + }) + + it('should stop painting and call fadeOut method when calling stop method', () => { + annotationCanvas.painting = true + const fadeOutSpy = jest.spyOn(annotationCanvas, 'fadeOut') + + annotationCanvas.stop() + + expect(annotationCanvas.painting).toBe(false) + expect(fadeOutSpy).toHaveBeenCalled() + }) + + it('should not stop painting or call fadeOut method when calling stop method while not painting', () => { + annotationCanvas.painting = false + const fadeOutSpy = jest.spyOn(annotationCanvas, 'fadeOut') + annotationCanvas.stop() + + expect(fadeOutSpy).not.toHaveBeenCalled() + }) + + it('should draw a line on the canvas when calling move method', () => { + annotationCanvas.painting = true + annotationCanvas.ctx = contextMock + const initialLastPosition = [0, 0,] + const position = [10, 20,] + + annotationCanvas.move(position) + + expect(contextMock.globalAlpha).toBe(1.0) + expect(contextMock.beginPath).toHaveBeenCalled() + expect(contextMock.moveTo).toHaveBeenCalledWith(initialLastPosition[0], initialLastPosition[1]) + expect(contextMock.lineTo).toHaveBeenCalledWith(position[0], position[1]) + expect(contextMock.stroke).toHaveBeenCalled() + expect(annotationCanvas.lastPosition).toEqual(position) + }) + + it('should not draw a line on the canvas when calling move method while not painting', () => { + annotationCanvas.painting = false + annotationCanvas.ctx = contextMock + const position = [10, 20,] + + annotationCanvas.move(position) + + expect(contextMock.beginPath).not.toHaveBeenCalled() + expect(contextMock.stroke).not.toHaveBeenCalled() + expect(annotationCanvas.lastPosition).toEqual([0, 0,]) + }) + + it('should fade out the canvas when calling fadeOut method', () => { + annotationCanvas.ctx = contextMock + jest.useFakeTimers() + const timerSpy = jest.spyOn(window, 'setTimeout') + annotationCanvas.fadeOut() + + expect(timerSpy).toHaveBeenCalledTimes(2) + expect(contextMock.globalCompositeOperation).toBe('source-over') + expect(contextMock.fillStyle).toBe('rgba(255, 255, 255, 0.1)') + expect(contextMock.fillRect).toHaveBeenCalledWith(0, 0, canvasMock.width, canvasMock.height) + jest.runOnlyPendingTimers() + expect(contextMock.clearRect).toHaveBeenCalledWith(0, 0, canvasMock.width, canvasMock.height) + }) + + it('should remove the canvas element when calling remove method', () => { + const spyOnRemove = jest.spyOn(document, 'removeChild') + annotationCanvas.remove() + + expect(spyOnRemove).toHaveBeenCalledWith(canvasMock) + expect(window.removeEventListener).toHaveBeenCalledWith('resize', annotationCanvas.resizeCanvas) + }) +}) \ No newline at end of file diff --git a/tracker/tracker-assist/tests/RemoteControl.test.ts b/tracker/tracker-assist/tests/RemoteControl.test.ts new file mode 100644 index 000000000..f8679576d --- /dev/null +++ b/tracker/tracker-assist/tests/RemoteControl.test.ts @@ -0,0 +1,208 @@ +import RemoteControl, { RCStatus, } from '../src/RemoteControl' +import ConfirmWindow from '../src/ConfirmWindow/ConfirmWindow' +import { describe, expect, test, jest, beforeEach, afterEach, } from '@jest/globals' + +describe('RemoteControl', () => { + let remoteControl + let options + let onGrand + let onRelease + let confirmWindowMountMock + let confirmWindowRemoveMock + + beforeEach(() => { + options = { + /* mock options */ + } + onGrand = jest.fn() + onRelease = jest.fn() + confirmWindowMountMock = jest.fn(() => Promise.resolve(true)) + confirmWindowRemoveMock = jest.fn() + + jest.spyOn(window, 'HTMLInputElement').mockImplementation((): any => ({ + value: '', + dispatchEvent: jest.fn(), + })) + + jest.spyOn(window, 'HTMLTextAreaElement').mockImplementation((): any => ({ + value: '', + dispatchEvent: jest.fn(), + })) + + jest + .spyOn(ConfirmWindow.prototype, 'mount') + .mockImplementation(confirmWindowMountMock) + jest + .spyOn(ConfirmWindow.prototype, 'remove') + .mockImplementation(confirmWindowRemoveMock) + + remoteControl = new RemoteControl(options, onGrand, onRelease) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + test('should initialize with disabled status', () => { + expect(remoteControl.status).toBe(RCStatus.Disabled) + expect(remoteControl.agentID).toBeNull() + expect(remoteControl.confirm).toBeNull() + expect(remoteControl.mouse).toBeNull() + }) + + test('should request control when calling requestControl method', () => { + const id = 'agent123' + remoteControl.requestControl(id) + + expect(remoteControl.agentID).toBe(id) + expect(remoteControl.status).toBe(RCStatus.Requesting) + expect(confirmWindowMountMock).toHaveBeenCalled() + }) + + test('should grant control when calling grantControl method', () => { + const id = 'agent123' + remoteControl.grantControl(id) + + expect(remoteControl.agentID).toBe(id) + expect(remoteControl.status).toBe(RCStatus.Enabled) + expect(onGrand).toHaveBeenCalledWith(id) + expect(remoteControl.mouse).toBeDefined() + }) + + test('should release control when calling releaseControl method', () => { + const isDenied = true + remoteControl['confirm'] = { remove: jest.fn(), } as unknown as ConfirmWindow + const confirmSpy = jest.spyOn(remoteControl['confirm'], 'remove') + + remoteControl.releaseControl(isDenied) + expect(remoteControl.agentID).toBeNull() + expect(remoteControl.status).toBe(RCStatus.Disabled) + expect(onRelease).toHaveBeenCalledWith(null, isDenied) + expect(confirmSpy).toHaveBeenCalled() + expect(remoteControl.mouse).toBeNull() + }) + + test('should reset mouse when calling resetMouse method', () => { + remoteControl.resetMouse() + + expect(remoteControl.mouse).toBeNull() + }) + + test('should call mouse.scroll when calling scroll method with correct agentID', () => { + const id = 'agent123' + const d = 10 + remoteControl.agentID = id + remoteControl.mouse = { + scroll: jest.fn(), + } + + remoteControl.scroll(id, d) + + expect(remoteControl.mouse.scroll).toHaveBeenCalledWith(d) + }) + + test('should not call mouse.scroll when calling scroll method with incorrect agentID', () => { + const id = 'agent123' + const d = 10 + remoteControl.agentID = 'anotherAgent' + remoteControl.mouse = { + scroll: jest.fn(), + } + + remoteControl.scroll(id, d) + + expect(remoteControl.mouse.scroll).not.toHaveBeenCalled() + }) + + test('should call mouse.move when calling move method with correct agentID', () => { + const id = 'agent123' + const xy = { x: 10, y: 20, } + remoteControl.agentID = id + remoteControl.mouse = { + move: jest.fn(), + } + + remoteControl.move(id, xy) + + expect(remoteControl.mouse.move).toHaveBeenCalledWith(xy) + }) + + test('should not call mouse.move when calling move method with incorrect agentID', () => { + const id = 'agent123' + const xy = { x: 10, y: 20, } + remoteControl.agentID = 'anotherAgent' + remoteControl.mouse = { + move: jest.fn(), + } + + remoteControl.move(id, xy) + + expect(remoteControl.mouse.move).not.toHaveBeenCalled() + }) + + test('should call mouse.click when calling click method with correct agentID', () => { + const id = 'agent123' + const xy = { x: 10, y: 20, } + remoteControl.agentID = id + remoteControl.mouse = { + click: jest.fn(), + } + + remoteControl.click(id, xy) + + expect(remoteControl.mouse.click).toHaveBeenCalledWith(xy) + }) + + test('should not call mouse.click when calling click method with incorrect agentID', () => { + const id = 'agent123' + const xy = { x: 10, y: 20, } + remoteControl.agentID = 'anotherAgent' + remoteControl.mouse = { + click: jest.fn(), + } + + remoteControl.click(id, xy) + + expect(remoteControl.mouse.click).not.toHaveBeenCalled() + }) + + test('should set the focused element when calling focus method', () => { + const id = 'agent123' + const element = document.createElement('div') + + remoteControl.focus(id, element) + + expect(remoteControl.focused).toBe(element) + }) + + test('should call setInputValue and dispatch input event when calling input method with HTMLInputElement', () => { + const id = 'agent1234' + const value = 'test_test' + const element = document.createElement('input') + const dispatchSpy = jest.spyOn(element, 'dispatchEvent') + remoteControl.agentID = id + remoteControl.mouse = true + remoteControl.focused = element + + remoteControl.input(id, value) + + expect(element.value).toBe(value) + expect(dispatchSpy).toHaveBeenCalledWith( + new Event('input', { bubbles: true, }) + ) + }) + + test('should update innerText when calling input method with content editable element', () => { + const id = 'agent123' + const value = 'test' + const element = document.createElement('div') + // @ts-ignore + element['isContentEditable'] = true + remoteControl.agentID = id + remoteControl.mouse = true + remoteControl.focused = element + + remoteControl.input(id, value) + expect(element.innerText).toBe(value) + }) +}) diff --git a/tracker/tracker-assist/tsconfig-cjs.json b/tracker/tracker-assist/tsconfig-cjs.json index 72d985654..7f85ad21f 100644 --- a/tracker/tracker-assist/tsconfig-cjs.json +++ b/tracker/tracker-assist/tsconfig-cjs.json @@ -2,6 +2,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "module": "CommonJS", - "outDir": "./cjs" + "outDir": "./cjs", + "rootDir": "src" }, + "exclude": ["**/*.test.ts"] } \ No newline at end of file diff --git a/tracker/tracker-assist/tsconfig.json b/tracker/tracker-assist/tsconfig.json index 2faaed678..bb9a17073 100644 --- a/tracker/tracker-assist/tsconfig.json +++ b/tracker/tracker-assist/tsconfig.json @@ -8,6 +8,8 @@ "moduleResolution": "node", "allowSyntheticDefaultImports": true, "declaration": true, - "outDir": "./lib" - } + "outDir": "./lib", + "rootDir": "src" + }, + "exclude": ["**/*.test.ts"] } diff --git a/tracker/tracker/CHANGELOG.md b/tracker/tracker/CHANGELOG.md index da9cb3173..bdebd9f56 100644 --- a/tracker/tracker/CHANGELOG.md +++ b/tracker/tracker/CHANGELOG.md @@ -1,3 +1,7 @@ +# 8.0.0 + +- **[breaking]** support for multi-tab sessions + # 7.0.3 - Prevent auto restart after manual stop diff --git a/tracker/tracker/package.json b/tracker/tracker/package.json index 93031dddf..0a1dcc089 100644 --- a/tracker/tracker/package.json +++ b/tracker/tracker/package.json @@ -1,7 +1,7 @@ { "name": "@openreplay/tracker", "description": "The OpenReplay tracker main package", - "version": "7.0.3", + "version": "8.0.0-beta.1", "keywords": [ "logging", "replay" @@ -22,7 +22,10 @@ "build": "npm run clean && npm run tscRun && npm run rollup && npm run compile", "prepare": "cd ../../ && husky install tracker/.husky/", "lint-front": "lint-staged", - "test": "jest" + "test": "jest --coverage=false", + "test:ci": "jest --coverage=true", + "postversion": "npm run build", + "prepublishOnly": "npm run build" }, "devDependencies": { "@babel/core": "^7.10.2", diff --git a/tracker/tracker/src/common/interaction.ts b/tracker/tracker/src/common/interaction.ts index 27bd4e73a..bdd1c0408 100644 --- a/tracker/tracker/src/common/interaction.ts +++ b/tracker/tracker/src/common/interaction.ts @@ -11,6 +11,7 @@ type Start = { pageNo: number timestamp: number url: string + tabId: string } & Options type Auth = { diff --git a/tracker/tracker/src/common/messages.gen.ts b/tracker/tracker/src/common/messages.gen.ts index a96343098..4eb2a22a1 100644 --- a/tracker/tracker/src/common/messages.gen.ts +++ b/tracker/tracker/src/common/messages.gen.ts @@ -68,6 +68,8 @@ export declare const enum Type { MouseThrashing = 114, UnbindNodes = 115, ResourceTiming = 116, + TabChange = 117, + TabData = 118, } @@ -536,6 +538,16 @@ export type ResourceTiming = [ /*cached:*/ boolean, ] +export type TabChange = [ + /*type:*/ Type.TabChange, + /*tabId:*/ string, +] -type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequest | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTimingDeprecated | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | InputChange | SelectionChange | MouseThrashing | UnbindNodes | ResourceTiming +export type TabData = [ + /*type:*/ Type.TabData, + /*tabId:*/ string, +] + + +type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequest | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTimingDeprecated | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | InputChange | SelectionChange | MouseThrashing | UnbindNodes | ResourceTiming | TabChange | TabData export default Message diff --git a/tracker/tracker/src/main/app/index.ts b/tracker/tracker/src/main/app/index.ts index e0c3311c4..a765d2b08 100644 --- a/tracker/tracker/src/main/app/index.ts +++ b/tracker/tracker/src/main/app/index.ts @@ -1,5 +1,5 @@ import type Message from './messages.gen.js' -import { Timestamp, Metadata, UserID, Type as MType } from './messages.gen.js' +import { Timestamp, Metadata, UserID, Type as MType, TabChange, TabData } from './messages.gen.js' import { now, adjustTimeOrigin, deprecationWarn } from '../utils.js' import Nodes from './nodes.js' import Observer from './observer/top_observer.js' @@ -46,6 +46,12 @@ type UnsuccessfulStart = { reason: typeof CANCELED | string success: false } + +type RickRoll = { source: string } & ( + | { line: 'never-gonna-give-you-up' } + | { line: 'never-gonna-let-you-down'; token: string } +) + const UnsuccessfulStart = (reason: string): UnsuccessfulStart => ({ reason, success: false }) const SuccessfulStart = (body: OnStartInfo): SuccessfulStart => ({ ...body, success: true }) export type StartPromiseReturn = SuccessfulStart | UnsuccessfulStart @@ -64,6 +70,7 @@ type AppOptions = { session_reset_key: string session_token_key: string session_pageno_key: string + session_tabid_key: string local_uuid_key: string ingestPoint: string resourceBaseHref: string | null // resourceHref? @@ -74,6 +81,7 @@ type AppOptions = { __debug__?: LoggerOptions localStorage: Storage | null sessionStorage: Storage | null + forceSingleTab?: boolean // @deprecated onStart?: StartCallback @@ -109,6 +117,7 @@ export default class App { private readonly worker?: TypedWorker private compressionThreshold = 24 * 1000 private restartAttempts = 0 + private readonly bc: BroadcastChannel = new BroadcastChannel('rick') constructor(projectKey: string, sessionToken: string | undefined, options: Partial) { // if (options.onStart !== undefined) { @@ -124,6 +133,7 @@ export default class App { session_token_key: '__openreplay_token', session_pageno_key: '__openreplay_pageno', session_reset_key: '__openreplay_reset', + session_tabid_key: '__openreplay_tabid', local_uuid_key: '__openreplay_uuid', ingestPoint: DEFAULT_INGEST_POINT, resourceBaseHref: null, @@ -132,6 +142,7 @@ export default class App { __debug_report_edp: null, localStorage: null, sessionStorage: null, + forceSingleTab: false, }, options, ) @@ -212,6 +223,30 @@ export default class App { } catch (e) { this._debug('worker_start', e) } + + const thisTab = this.session.getTabId() + + if (!this.session.getSessionToken() && !this.options.forceSingleTab) { + this.bc.postMessage({ line: 'never-gonna-give-you-up', source: thisTab }) + } + + this.bc.onmessage = (ev: MessageEvent) => { + if (ev.data.source === thisTab) return + if (ev.data.line === 'never-gonna-let-you-down') { + const sessionToken = ev.data.token + this.session.setSessionToken(sessionToken) + } + if (ev.data.line === 'never-gonna-give-you-up') { + const token = this.session.getSessionToken() + if (token) { + this.bc.postMessage({ + line: 'never-gonna-let-you-down', + token, + source: thisTab, + }) + } + } + } } private _debug(context: string, e: any) { @@ -257,6 +292,7 @@ export default class App { } private commit(): void { if (this.worker && this.messages.length) { + this.messages.unshift(TabData(this.session.getTabId())) this.messages.unshift(Timestamp(this.timestamp())) this.worker.postMessage(this.messages) this.commitCallbacks.forEach((cb) => cb(this.messages)) @@ -455,12 +491,16 @@ export default class App { url: document.URL, connAttemptCount: this.options.connAttemptCount, connAttemptGap: this.options.connAttemptGap, + tabId: this.session.getTabId(), }) const lsReset = this.sessionStorage.getItem(this.options.session_reset_key) !== null this.sessionStorage.removeItem(this.options.session_reset_key) const needNewSessionID = startOpts.forceNew || lsReset || resetByWorker + const sessionToken = this.session.getSessionToken() + const isNewSession = needNewSessionID || !sessionToken + console.log('OpenReplay: starting session', needNewSessionID, sessionToken) return window .fetch(this.options.ingestPoint + '/v1/web/start', { method: 'POST', @@ -471,7 +511,7 @@ export default class App { ...this.getTrackerInfo(), timestamp, userID: this.session.getInfo().userID, - token: needNewSessionID ? undefined : this.session.getSessionToken(), + token: isNewSession ? undefined : sessionToken, deviceMemory, jsHeapSizeLimit, }), @@ -523,6 +563,11 @@ export default class App { timestamp: startTimestamp || timestamp, projectID, }) + if (!isNewSession && token === sessionToken) { + console.log('continuing session on new tab', this.session.getTabId()) + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.send(TabChange(this.session.getTabId())) + } // (Re)send Metadata for the case of a new session Object.entries(this.session.getInfo().metadata).forEach(([key, value]) => this.send(Metadata(key, value)), @@ -566,21 +611,35 @@ export default class App { }) } + /** + * basically we ask other tabs during constructor + * and here we just apply 10ms delay just in case + * */ start(...args: Parameters): Promise { if (!document.hidden) { - return this._start(...args) + return new Promise((resolve) => { + setTimeout(() => { + resolve(this._start(...args)) + }, 10) + }) } else { return new Promise((resolve) => { const onVisibilityChange = () => { if (!document.hidden) { document.removeEventListener('visibilitychange', onVisibilityChange) - resolve(this._start(...args)) + setTimeout(() => { + resolve(this._start(...args)) + }, 10) } } document.addEventListener('visibilitychange', onVisibilityChange) }) } } + + getTabId() { + return this.session.getTabId() + } stop(stopWorker = true): void { if (this.activityState !== ActivityState.NotActive) { try { diff --git a/tracker/tracker/src/main/app/messages.gen.ts b/tracker/tracker/src/main/app/messages.gen.ts index 46d672d2d..3fdcf7ccb 100644 --- a/tracker/tracker/src/main/app/messages.gen.ts +++ b/tracker/tracker/src/main/app/messages.gen.ts @@ -869,3 +869,21 @@ export function ResourceTiming( ] } +export function TabChange( + tabId: string, +): Messages.TabChange { + return [ + Messages.Type.TabChange, + tabId, + ] +} + +export function TabData( + tabId: string, +): Messages.TabData { + return [ + Messages.Type.TabData, + tabId, + ] +} + diff --git a/tracker/tracker/src/main/app/sanitizer.ts b/tracker/tracker/src/main/app/sanitizer.ts index 629d8ed5d..faeda2702 100644 --- a/tracker/tracker/src/main/app/sanitizer.ts +++ b/tracker/tracker/src/main/app/sanitizer.ts @@ -14,6 +14,11 @@ export interface Options { domSanitizer?: (node: Element) => SanitizeLevel } +export const stringWiper = (input: string) => + input + .trim() + .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g, '█') + export default class Sanitizer { private readonly obscured: Set = new Set() private readonly hidden: Set = new Set() @@ -59,10 +64,9 @@ export default class Sanitizer { sanitize(id: number, data: string): string { if (this.obscured.has(id)) { // TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases? - return data - .trim() - .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g, '█') + return stringWiper(data) } + if (this.options.obscureTextNumbers) { data = data.replace(/\d/g, '0') } diff --git a/tracker/tracker/src/main/app/session.ts b/tracker/tracker/src/main/app/session.ts index 4682bcc43..ecf1a984c 100644 --- a/tracker/tracker/src/main/app/session.ts +++ b/tracker/tracker/src/main/app/session.ts @@ -1,4 +1,5 @@ import type App from './index.js' +import { generateRandomId } from '../utils.js' interface SessionInfo { sessionID: string | undefined @@ -12,6 +13,7 @@ type OnUpdateCallback = (i: Partial) => void export type Options = { session_token_key: string session_pageno_key: string + session_tabid_key: string } export default class Session { @@ -21,8 +23,11 @@ export default class Session { private readonly callbacks: OnUpdateCallback[] = [] private timestamp = 0 private projectID: string | undefined + private tabId: string - constructor(private readonly app: App, private readonly options: Options) {} + constructor(private readonly app: App, private readonly options: Options) { + this.createTabId() + } attachUpdateCallback(cb: OnUpdateCallback) { this.callbacks.push(cb) @@ -61,6 +66,7 @@ export default class Session { this.metadata[key] = value this.handleUpdate({ metadata: { [key]: value } }) } + setUserID(userID: string) { this.userID = userID this.handleUpdate({ userID }) @@ -88,6 +94,7 @@ export default class Session { getSessionToken(): string | undefined { return this.app.sessionStorage.getItem(this.options.session_token_key) || undefined } + setSessionToken(token: string): void { this.app.sessionStorage.setItem(this.options.session_token_key, token) } @@ -115,6 +122,22 @@ export default class Session { return encodeURI(String(pageNo) + '&' + token) } + public getTabId(): string { + if (!this.tabId) this.createTabId() + return this.tabId + } + + private createTabId() { + const localId = this.app.sessionStorage.getItem(this.options.session_tabid_key) + if (localId) { + this.tabId = localId + } else { + const randomId = generateRandomId(12) + this.app.sessionStorage.setItem(this.options.session_tabid_key, randomId) + this.tabId = randomId + } + } + getInfo(): SessionInfo { return { sessionID: this.sessionID, diff --git a/tracker/tracker/src/main/index.ts b/tracker/tracker/src/main/index.ts index bc4ff0775..d15fc23fa 100644 --- a/tracker/tracker/src/main/index.ts +++ b/tracker/tracker/src/main/index.ts @@ -25,6 +25,7 @@ import Fonts from './modules/fonts.js' import Network from './modules/network.js' import ConstructedStyleSheets from './modules/constructedStyleSheets.js' import Selection from './modules/selection.js' +import Tabs from './modules/tabs.js' import { IN_BROWSER, deprecationWarn, DOCS_HOST } from './utils.js' import type { Options as AppOptions } from './app/index.js' @@ -136,6 +137,7 @@ export default class API { Fonts(app) Network(app, options.network) Selection(app) + Tabs(app) ;(window as any).__OPENREPLAY__ = this if (options.autoResetOnWindowOpen) { @@ -216,6 +218,13 @@ export default class API { } return this.app.getSessionID() } + + getTabId() { + if (this.app === null) { + return null + } + return this.app.getTabId() + } sessionID(): string | null | undefined { deprecationWarn("'sessionID' method", "'getSessionID' method", '/') return this.getSessionID() diff --git a/tracker/tracker/src/main/modules/tabs.ts b/tracker/tracker/src/main/modules/tabs.ts new file mode 100644 index 000000000..58c6d4efa --- /dev/null +++ b/tracker/tracker/src/main/modules/tabs.ts @@ -0,0 +1,13 @@ +import type App from '../app/index.js' +import { TabChange } from '../app/messages.gen.js' + +export default function (app: App): void { + function changeTab() { + if (!document.hidden) { + app.debug.log('Openreplay: tab change to' + app.session.getTabId()) + app.send(TabChange(app.session.getTabId())) + } + } + + app.attachEventListener(window, 'focus', changeTab as EventListener, false, false) +} diff --git a/tracker/tracker/src/main/utils.ts b/tracker/tracker/src/main/utils.ts index 4ef650a0b..5b33503f6 100644 --- a/tracker/tracker/src/main/utils.ts +++ b/tracker/tracker/src/main/utils.ts @@ -82,14 +82,6 @@ export function hasOpenreplayAttribute(e: Element, attr: string): boolean { return false } -export function isIframeCrossdomain(e: HTMLIFrameElement): boolean { - try { - return e.contentWindow?.location.href !== window.location.href - } catch (e) { - return true - } -} - /** * checks if iframe is accessible **/ @@ -100,3 +92,16 @@ export function canAccessIframe(iframe: HTMLIFrameElement) { return false } } + +function dec2hex(dec: number) { + return dec.toString(16).padStart(2, '0') +} + +export function generateRandomId(len?: number) { + const arr: Uint8Array = new Uint8Array((len || 40) / 2) + // msCrypto = IE11 + // @ts-ignore + const safeCrypto = window.crypto || window.msCrypto + safeCrypto.getRandomValues(arr) + return Array.from(arr, dec2hex).join('') +} diff --git a/tracker/tracker/src/tests/guards.unit.test.ts b/tracker/tracker/src/tests/guards.unit.test.ts new file mode 100644 index 000000000..6690288b3 --- /dev/null +++ b/tracker/tracker/src/tests/guards.unit.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from '@jest/globals' +import { + isNode, + isSVGElement, + isElementNode, + isCommentNode, + isTextNode, + isDocument, + isRootNode, + hasTag, +} from '../main/app/guards.js' + +describe('isNode', () => { + test('returns true for a valid Node object', () => { + const node = document.createElement('div') + expect(isNode(node)).toBe(true) + }) + + test('returns false for a non-Node object', () => { + const obj = { foo: 'bar' } + expect(isNode(obj)).toBe(false) + }) +}) + +describe('isSVGElement', () => { + test('returns true for an SVGElement object', () => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + expect(isSVGElement(svg)).toBe(true) + }) + + test('returns false for a non-SVGElement object', () => { + const div = document.createElement('div') + expect(isSVGElement(div)).toBe(false) + }) +}) + +describe('isElementNode', () => { + test('returns true for an Element object', () => { + const element = document.createElement('div') + expect(isElementNode(element)).toBe(true) + }) + + test('returns false for a non-Element object', () => { + const textNode = document.createTextNode('Hello') + expect(isElementNode(textNode)).toBe(false) + }) +}) + +describe('isCommentNode', () => { + test('returns true for a Comment object', () => { + const comment = document.createComment('This is a comment') + expect(isCommentNode(comment)).toBe(true) + }) + + test('returns false for a non-Comment object', () => { + const div = document.createElement('div') + expect(isCommentNode(div)).toBe(false) + }) +}) + +describe('isTextNode', () => { + test('returns true for a Text object', () => { + const textNode = document.createTextNode('Hello') + expect(isTextNode(textNode)).toBe(true) + }) + + test('returns false for a non-Text object', () => { + const div = document.createElement('div') + expect(isTextNode(div)).toBe(false) + }) +}) + +describe('isDocument', () => { + test('returns true for a Document object', () => { + const documentObj = document.implementation.createHTMLDocument('Test') + expect(isDocument(documentObj)).toBe(true) + }) + + test('returns false for a non-Document object', () => { + const div = document.createElement('div') + expect(isDocument(div)).toBe(false) + }) +}) + +describe('isRootNode', () => { + test('returns true for a Document object', () => { + const documentObj = document.implementation.createHTMLDocument('Test') + expect(isRootNode(documentObj)).toBe(true) + }) + + test('returns true for a DocumentFragment object', () => { + const fragment = document.createDocumentFragment() + expect(isRootNode(fragment)).toBe(true) + }) + + test('returns false for a non-root Node object', () => { + const div = document.createElement('div') + expect(isRootNode(div)).toBe(false) + }) +}) + +describe('hasTag', () => { + test('returns true if the element has the specified tag name', () => { + const element = document.createElement('input') + expect(hasTag(element, 'input')).toBe(true) + }) + + test('returns false if the element does not have the specified tag name', () => { + const element = document.createElement('div') + // @ts-expect-error + expect(hasTag(element, 'span')).toBe(false) + }) +}) diff --git a/tracker/tracker/src/tests/sanitizer.unit.test.ts b/tracker/tracker/src/tests/sanitizer.unit.test.ts new file mode 100644 index 000000000..30037a0e8 --- /dev/null +++ b/tracker/tracker/src/tests/sanitizer.unit.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, jest, afterEach, beforeEach, test } from '@jest/globals' +import Sanitizer, { SanitizeLevel, Options, stringWiper } from '../main/app/sanitizer.js' + +describe('stringWiper', () => { + test('should replace all characters with █', () => { + expect(stringWiper('Sensitive Data')).toBe('██████████████') + }) +}) + +describe('Sanitizer', () => { + let sanitizer: Sanitizer + + beforeEach(() => { + const options: Options = { + obscureTextEmails: true, + obscureTextNumbers: false, + domSanitizer: undefined, + } + const app = { + nodes: { + getID: (el: { mockId: number }) => el.mockId, + }, + } + // @ts-expect-error + sanitizer = new Sanitizer(app, options) + }) + + afterEach(() => { + sanitizer.clear() + }) + + test('should handle node and mark it as obscured if parent is obscured', () => { + sanitizer['obscured'].add(2) + sanitizer.handleNode(1, 2, document.createElement('div')) + expect(sanitizer.isObscured(1)).toBe(true) + }) + + test('should handle node and mark it as obscured if it has "masked" or "obscured" attribute', () => { + const node = document.createElement('div') + node.setAttribute('data-openreplay-obscured', '') + sanitizer.handleNode(1, 2, node) + expect(sanitizer.isObscured(1)).toBe(true) + }) + + test('should handle node and mark it as hidden if parent is hidden', () => { + sanitizer['hidden'].add(2) + sanitizer.handleNode(1, 2, document.createElement('div')) + expect(sanitizer.isHidden(1)).toBe(true) + }) + + test('should handle node and mark it as hidden if it has "htmlmasked" or "hidden" attribute', () => { + const node = document.createElement('div') + node.setAttribute('data-openreplay-hidden', '') + sanitizer.handleNode(1, 2, node) + expect(sanitizer.isHidden(1)).toBe(true) + }) + + test('should handle node and sanitize based on custom domSanitizer function', () => { + const domSanitizer = (node: Element): SanitizeLevel => { + if (node.tagName === 'SPAN') { + return SanitizeLevel.Obscured + } + if (node.tagName === 'DIV') { + return SanitizeLevel.Hidden + } + return SanitizeLevel.Plain + } + + const options: Options = { + obscureTextEmails: true, + obscureTextNumbers: false, + domSanitizer, + } + const app = { + nodes: { + getID: jest.fn(), + }, + } + + // @ts-expect-error + sanitizer = new Sanitizer(app, options) + + const spanNode = document.createElement('span') + const divNode = document.createElement('div') + const plainNode = document.createElement('p') + + sanitizer.handleNode(1, 2, spanNode) + sanitizer.handleNode(3, 4, divNode) + sanitizer.handleNode(5, 6, plainNode) + + expect(sanitizer.isObscured(1)).toBe(true) + expect(sanitizer.isHidden(3)).toBe(true) + expect(sanitizer.isObscured(5)).toBe(false) + expect(sanitizer.isHidden(5)).toBe(false) + }) + + test('should sanitize data as obscured if node is marked as obscured', () => { + sanitizer['obscured'].add(1) + const data = 'Sensitive Data' + + const sanitizedData = sanitizer.sanitize(1, data) + expect(sanitizedData).toEqual(stringWiper(data)) + }) + + test('should sanitize data by obscuring text numbers if enabled', () => { + sanitizer['options'].obscureTextNumbers = true + const data = 'Phone: 123-456-7890' + const sanitizedData = sanitizer.sanitize(1, data) + expect(sanitizedData).toEqual('Phone: 000-000-0000') + }) + + test('should sanitize data by obscuring text emails if enabled', () => { + sanitizer['options'].obscureTextEmails = true + const data = 'john.doe@example.com' + const sanitizedData = sanitizer.sanitize(1, data) + expect(sanitizedData).toEqual('********@*******.***') + }) + + test('should return inner text of an element securely by sanitizing it', () => { + const element = document.createElement('div') + sanitizer['obscured'].add(1) + // @ts-expect-error + element.mockId = 1 + element.innerText = 'Sensitive Data' + const sanitizedText = sanitizer.getInnerTextSecure(element) + expect(sanitizedText).toEqual('██████████████') + }) + + test('should return empty string if node element does not exist', () => { + const element = document.createElement('div') + element.innerText = 'Sensitive Data' + const sanitizedText = sanitizer.getInnerTextSecure(element) + expect(sanitizedText).toEqual('') + }) +}) diff --git a/tracker/tracker/src/tests/utils.unit.test.ts b/tracker/tracker/src/tests/utils.unit.test.ts new file mode 100644 index 000000000..2b7836a70 --- /dev/null +++ b/tracker/tracker/src/tests/utils.unit.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test, jest, afterEach, beforeEach } from '@jest/globals' +import { + adjustTimeOrigin, + getTimeOrigin, + now, + stars, + normSpaces, + isURL, + deprecationWarn, + getLabelAttribute, + hasOpenreplayAttribute, + canAccessIframe, + generateRandomId, +} from '../main/utils.js' + +describe('adjustTimeOrigin', () => { + test('adjusts the time origin based on performance.now', () => { + jest.spyOn(Date, 'now').mockReturnValue(1000) + jest.spyOn(performance, 'now').mockReturnValue(1000) + adjustTimeOrigin() + + expect(getTimeOrigin()).toBe(0) + }) +}) + +describe('now', () => { + test('returns the current timestamp in milliseconds', () => { + jest.spyOn(Date, 'now').mockReturnValue(2550) + jest.spyOn(performance, 'now').mockReturnValue(2550) + + adjustTimeOrigin() + + expect(now()).toBe(2550) + }) +}) + +describe('stars', () => { + test('returns a string of asterisks with the same length as the input string', () => { + expect(stars('hello')).toBe('*****') + }) + + test('returns an empty string if the input string is empty', () => { + expect(stars('')).toBe('') + }) +}) + +describe('normSpaces', () => { + test('trims the string and replaces multiple spaces with a single space', () => { + expect(normSpaces(' hello world ')).toBe('hello world') + }) + + test('returns an empty string if the input string is empty', () => { + expect(normSpaces('')).toBe('') + }) +}) + +describe('isURL', () => { + test('returns true for a valid URL starting with "https://"', () => { + expect(isURL('https://example.com')).toBe(true) + }) + + test('returns true for a valid URL starting with "http://"', () => { + expect(isURL('http://example.com')).toBe(true) + }) + + test('returns false for a URL without a valid protocol', () => { + expect(isURL('example.com')).toBe(false) + }) + + test('returns false for an empty string', () => { + expect(isURL('')).toBe(false) + }) +}) + +describe('deprecationWarn', () => { + let consoleWarnSpy: jest.SpiedFunction<(args: any) => void> + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation((args) => args) + }) + + afterEach(() => { + consoleWarnSpy.mockRestore() + }) + + test('prints a warning message for a deprecated feature', () => { + deprecationWarn('oldFeature', 'newFeature') + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'OpenReplay: oldFeature is deprecated. Please, use newFeature instead. Visit https://docs.openreplay.com/ for more information.', + ) + }) + + test('does not print a warning message for a deprecated feature that has already been warned', () => { + deprecationWarn('oldFeature2', 'newFeature') + deprecationWarn('oldFeature2', 'newFeature') + expect(consoleWarnSpy).toHaveBeenCalledTimes(1) + }) +}) + +describe('getLabelAttribute', () => { + test('returns the value of "data-openreplay-label" attribute if present', () => { + const element = document.createElement('div') + element.setAttribute('data-openreplay-label', 'Label') + expect(getLabelAttribute(element)).toBe('Label') + }) + + test('returns the value of "data-asayer-label" attribute if "data-openreplay-label" is not present (with deprecation warning)', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation((args) => args) + const element = document.createElement('div') + element.setAttribute('data-asayer-label', 'Label') + expect(getLabelAttribute(element)).toBe('Label') + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'OpenReplay: "data-asayer-label" attribute is deprecated. Please, use "data-openreplay-label" attribute instead. Visit https://docs.openreplay.com/ for more information.', + ) + consoleWarnSpy.mockRestore() + }) + + test('returns null if neither "data-openreplay-label" nor "data-asayer-label" are present', () => { + const element = document.createElement('div') + expect(getLabelAttribute(element)).toBeNull() + }) +}) + +describe('hasOpenreplayAttribute', () => { + let consoleWarnSpy: jest.SpiedFunction<(args: any) => void> + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation((args) => args) + }) + + afterEach(() => { + consoleWarnSpy.mockRestore() + }) + + test('returns true and prints a deprecation warning for a deprecated openreplay attribute', () => { + const element = document.createElement('div') + element.setAttribute('data-openreplay-htmlmasked', 'true') + const result = hasOpenreplayAttribute(element, 'htmlmasked') + expect(result).toBe(true) + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'OpenReplay: "data-openreplay-htmlmasked" attribute is deprecated. Please, use "hidden" attribute instead. Visit https://docs.openreplay.com/installation/sanitize-data for more information.', + ) + }) + + test('returns false for a non-existent openreplay attribute', () => { + const element = document.createElement('div') + const result = hasOpenreplayAttribute(element, 'nonexistent') + expect(result).toBe(false) + expect(consoleWarnSpy).not.toHaveBeenCalled() + }) +}) + +describe('canAccessIframe', () => { + test('returns true if the iframe has a contentDocument', () => { + const iframe = document.createElement('iframe') + Object.defineProperty(iframe, 'contentDocument', { + get: () => document.createElement('div'), + }) + expect(canAccessIframe(iframe)).toBe(true) + }) + + test('returns false if the iframe does not have a contentDocument', () => { + const iframe = document.createElement('iframe') + // Mock iframe.contentDocument to throw an error + Object.defineProperty(iframe, 'contentDocument', { + get: () => { + throw new Error('securityError') + }, + }) + expect(canAccessIframe(iframe)).toBe(false) + }) +}) + +describe('generateRandomId', () => { + test('generates a random ID with the specified length', () => { + const id = generateRandomId(10) + expect(id).toHaveLength(10) + expect(/^[0-9a-f]+$/.test(id)).toBe(true) + }) + + test('generates a random ID with the default length if no length is specified', () => { + const id = generateRandomId() + expect(id).toHaveLength(40) + expect(/^[0-9a-f]+$/.test(id)).toBe(true) + }) +}) diff --git a/tracker/tracker/src/webworker/BatchWriter.ts b/tracker/tracker/src/webworker/BatchWriter.ts index bd1d42084..e69784768 100644 --- a/tracker/tracker/src/webworker/BatchWriter.ts +++ b/tracker/tracker/src/webworker/BatchWriter.ts @@ -19,6 +19,7 @@ export default class BatchWriter { private timestamp: number, private url: string, private readonly onBatch: (batch: Uint8Array) => void, + private tabId: string, ) { this.prepare() } @@ -51,8 +52,12 @@ export default class BatchWriter { this.timestamp, this.url, ] + + const tabData: Messages.TabData = [Messages.Type.TabData, this.tabId] + this.writeType(batchMetadata) this.writeFields(batchMetadata) + this.writeWithSize(tabData as Message) this.isEmpty = true } diff --git a/tracker/tracker/src/webworker/BatchWriter.unit.test.ts b/tracker/tracker/src/webworker/BatchWriter.unit.test.ts index e9f039988..14dfdfd83 100644 --- a/tracker/tracker/src/webworker/BatchWriter.unit.test.ts +++ b/tracker/tracker/src/webworker/BatchWriter.unit.test.ts @@ -9,7 +9,7 @@ describe('BatchWriter', () => { beforeEach(() => { onBatchMock = jest.fn() - batchWriter = new BatchWriter(1, 123456789, 'example.com', onBatchMock) + batchWriter = new BatchWriter(1, 123456789, 'example.com', onBatchMock, '123') }) afterEach(() => { @@ -21,7 +21,8 @@ describe('BatchWriter', () => { expect(batchWriter['timestamp']).toBe(123456789) expect(batchWriter['url']).toBe('example.com') expect(batchWriter['onBatch']).toBe(onBatchMock) - expect(batchWriter['nextIndex']).toBe(0) + // we add tab id as first in the batch + expect(batchWriter['nextIndex']).toBe(1) expect(batchWriter['beaconSize']).toBe(200000) expect(batchWriter['encoder']).toBeDefined() expect(batchWriter['strDict']).toBeDefined() @@ -30,12 +31,14 @@ describe('BatchWriter', () => { }) test('writeType writes the type of the message', () => { + // @ts-ignore const message = [Messages.Type.BatchMetadata, 1, 2, 3, 4, 'example.com'] const result = batchWriter['writeType'](message as Message) expect(result).toBe(true) }) test('writeFields encodes the message fields', () => { + // @ts-ignore const message = [Messages.Type.BatchMetadata, 1, 2, 3, 4, 'example.com'] const result = batchWriter['writeFields'](message as Message) expect(result).toBe(true) @@ -52,6 +55,7 @@ describe('BatchWriter', () => { }) test('writeWithSize writes the message with its size', () => { + // @ts-ignore const message = [Messages.Type.BatchMetadata, 1, 2, 3, 4, 'example.com'] const result = batchWriter['writeWithSize'](message as Message) expect(result).toBe(true) @@ -72,6 +76,7 @@ describe('BatchWriter', () => { }) test('writeMessage writes the given message', () => { + // @ts-ignore const message = [Messages.Type.Timestamp, 987654321] // @ts-ignore batchWriter['writeWithSize'] = jest.fn().mockReturnValue(true) diff --git a/tracker/tracker/src/webworker/MessageEncoder.gen.ts b/tracker/tracker/src/webworker/MessageEncoder.gen.ts index 69fc7b35f..a6671967c 100644 --- a/tracker/tracker/src/webworker/MessageEncoder.gen.ts +++ b/tracker/tracker/src/webworker/MessageEncoder.gen.ts @@ -274,6 +274,14 @@ export default class MessageEncoder extends PrimitiveEncoder { return this.uint(msg[1]) && this.uint(msg[2]) && this.uint(msg[3]) && this.uint(msg[4]) && this.uint(msg[5]) && this.uint(msg[6]) && this.string(msg[7]) && this.string(msg[8]) && this.uint(msg[9]) && this.boolean(msg[10]) break + case Messages.Type.TabChange: + return this.string(msg[1]) + break + + case Messages.Type.TabData: + return this.string(msg[1]) + break + } } diff --git a/tracker/tracker/src/webworker/QueueSender.ts b/tracker/tracker/src/webworker/QueueSender.ts index f8cbaa43e..83b7e8f7b 100644 --- a/tracker/tracker/src/webworker/QueueSender.ts +++ b/tracker/tracker/src/webworker/QueueSender.ts @@ -135,6 +135,6 @@ export default class QueueSender { setTimeout(() => { this.token = null this.queue.length = 0 - }, 100) + }, 10) } } diff --git a/tracker/tracker/src/webworker/QueueSender.unit.test.ts b/tracker/tracker/src/webworker/QueueSender.unit.test.ts index 79fba9e71..6d7aee471 100644 --- a/tracker/tracker/src/webworker/QueueSender.unit.test.ts +++ b/tracker/tracker/src/webworker/QueueSender.unit.test.ts @@ -4,13 +4,11 @@ import QueueSender from './QueueSender.js' global.fetch = () => Promise.resolve(new Response()) // jsdom does not have it function mockFetch(status: number, headers?: Record) { - return jest - .spyOn(global, 'fetch') - .mockImplementation((request) => - Promise.resolve({ status, headers, request } as unknown as Response & { - request: RequestInfo - }), - ) + return jest.spyOn(global, 'fetch').mockImplementation((request) => + Promise.resolve({ status, headers, request } as unknown as Response & { + request: RequestInfo + }), + ) } const baseURL = 'MYBASEURL' const sampleArray = new Uint8Array(1) @@ -40,6 +38,7 @@ function defaultQueueSender({ describe('QueueSender', () => { afterEach(() => { jest.restoreAllMocks() + jest.useRealTimers() }) // Test fetch first parameter + authorization header to be present @@ -93,9 +92,10 @@ describe('QueueSender', () => { test("Doesn't call fetch on push() after clean()", () => { const queueSender = defaultQueueSender() const fetchMock = mockFetch(200) - + jest.useFakeTimers() queueSender.authorise(randomToken) queueSender.clean() + jest.runAllTimers() queueSender.push(sampleArray) expect(fetchMock).not.toBeCalled() }) diff --git a/tracker/tracker/src/webworker/index.ts b/tracker/tracker/src/webworker/index.ts index 68a1e4467..c7f781669 100644 --- a/tracker/tracker/src/webworker/index.ts +++ b/tracker/tracker/src/webworker/index.ts @@ -21,8 +21,9 @@ const AUTO_SEND_INTERVAL = 10 * 1000 let sender: QueueSender | null = null let writer: BatchWriter | null = null +// eslint-disable-next-line @typescript-eslint/no-unused-vars let workerStatus: WorkerStatus = WorkerStatus.NotActive -// let afterSleepRestarts = 0 + function finalize(): void { if (!writer) { return @@ -44,7 +45,7 @@ function resetSender(): void { // allowing some time to send last batch setTimeout(() => { sender = null - }, 500) + }, 20) } } @@ -56,7 +57,9 @@ function reset(): void { } resetWriter() resetSender() - workerStatus = WorkerStatus.NotActive + setTimeout(() => { + workerStatus = WorkerStatus.NotActive + }, 100) } function initiateRestart(): void { @@ -73,7 +76,7 @@ let sendIntervalID: ReturnType | null = null let restartTimeoutID: ReturnType // @ts-ignore -self.onmessage = ({ data }: any): any => { +self.onmessage = ({ data }: { data: ToWorkerData }): any => { if (data == null) { finalize() return @@ -146,6 +149,7 @@ self.onmessage = ({ data }: any): any => { data.timestamp, data.url, (batch) => sender && sender.push(batch), + data.tabId, ) if (sendIntervalID === null) { sendIntervalID = setInterval(finalize, AUTO_SEND_INTERVAL)