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 (
<>
-
- >
- )}
-
-
-
- {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)