feat(tracker/ui): support for multi tab sessions (#1236)

* feat(tracker): add support for multi tab sessions

* feat(backend): added support of multitabs

* fix(backend): added support of deprecated batch meta message to pre-decoder

* fix(backend): fixed nil meta issue for TabData messages in sink

* feat(player): add tabmanager

* feat(player): basic tabchange event support

* feat(player): pick tabstate for console panel and timeline

* fix(player): only display tabs that are created

* feat(player): connect performance, xray and events to tab state

* feat(player): merge all tabs data for overview

* feat(backend/tracker): extract tabdata into separate message from batchmeta

* fix(tracker): fix new session check

* fix(backend): remove batchmetadeprecated

* fix(backend): fix switch case

* fix(player): fix for tab message size

* feat(tracker): check for active tabs with broadcast channel

* feat(tracker): prevent multiple messages

* fix(tracker): ignore beacons from same tab, only ask if token isnt present yet, add small delay before start to wait for answer

* feat(player): support new msg struct in assist player

* fix(player): fix some livepl components for multi tab states

* feat(tracker): add option to disable multitab

* feat(tracker): add multitab to assist plugin

* feat(player): back compat for tab id

* fix(ui): fix missing list in controls

* fix(ui): optional list update

* feat(ui): fix visuals for multitab; use window focus event for tabs

* fix(tracker): fix for dying tests (added tabid to writer, refactored other tests)

* feat(ui): update LivePlayerSubHeader.tsx to support tabs

* feat(backend): added tabs support to devtools mob files

* feat(ui): connect state to current tab properly

* feat(backend): added multitab support to assits

* feat(backend): removed data check in agent message

* feat(backend): debug on

* fix(backend): fixed typo in message broadcast

* feat(backend): fixed issue in connect method

* fix(assist): fixed typo

* feat(assist): added more debug logs

* feat(assist): removed one log

* feat(assist): more logs

* feat(assist): use query.peerId

* feat(assist): more logs

* feat(assist): fixed session update

* fix(assist): fixed getSessions

* fix(assist): fixed request_control broadcast

* fix(assist): fixed typo

* fix(assist): added missed line

* fix(assist): fix typo

* feat(tracker): multitab support for assist sessions

* fix(tracker): fix dead tests (tabid prop)

* fix(tracker): fix yaml

* fix(tracker): timers issue

* fix(ui): fix ui E2E tests with magic?

* feat(assist): multitabs support for ee version

* fix(assist): added missed method import

* fix(tracker): fix fix events in assist

* feat(assist): added back compatibility for sessions without tabId

* fix(assist): apply message's top layer structure before broadcast call

* fix(assist): added random tabID for prev version

* fix(assist): added random tabID for prev version (ee)

* feat(assist): added debug logs

* fix(assist): fix typo in sessions_agents_count method

* fix(assist): fixed more typos in copy-pastes

* fix(tracker): fix restart timings

* feat(backend): added tabIDs for some events

* feat(ui): add tab change event to the user steps bar

* Revert "feat(backend): added tabIDs for some events"

This reverts commit 1467ad7f9f.

* feat(ui): revert timeline and xray to grab events from all tabs

* fix(ui): fix typo

---------

Co-authored-by: Alexander Zavorotynskiy <zavorotynskiy@pm.me>
This commit is contained in:
Delirium 2023-06-07 10:40:32 +02:00 committed by GitHub
parent fe0840ee84
commit 2ed4bba33e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
94 changed files with 2749 additions and 1540 deletions

65
.github/workflows/tracker-tests.yaml vendored Normal file
View file

@ -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

View file

@ -47,16 +47,6 @@ jobs:
cd tracker/tracker cd tracker/tracker
npm i -g yarn npm i -g yarn
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 - name: Build tracker inst
run: | run: |
cd tracker/tracker cd tracker/tracker

View file

@ -1,6 +1,7 @@
const _io = require('socket.io'); const _io = require('socket.io');
const express = require('express'); const express = require('express');
const { const {
extractRoomId,
extractPeerId, extractPeerId,
extractProjectKeyFromRequest, extractProjectKeyFromRequest,
extractSessionIdFromRequest, extractSessionIdFromRequest,
@ -24,7 +25,7 @@ const {
const wsRouter = express.Router(); const wsRouter = express.Router();
let io; let io;
const debug = process.env.debug === "1"; const debug = true;//process.env.debug === "1";
const createSocketIOServer = function (server, prefix) { const createSocketIOServer = function (server, prefix) {
io = _io(server, { io = _io(server, {
@ -47,25 +48,30 @@ const respond = function (res, data) {
const socketsList = async function (req, res) { const socketsList = async function (req, res) {
debug && console.log("[WS]looking for all available sessions"); debug && console.log("[WS]looking for all available sessions");
let filters = await extractPayloadFromRequest(req); let filters = await extractPayloadFromRequest(req);
let liveSessions = {}; let withFilters = hasFilters(filters);
let liveSessionsPerProject = {};
let rooms = await getAvailableRooms(io); let rooms = await getAvailableRooms(io);
for (let peerId of rooms.keys()) { for (let roomId of rooms.keys()) {
let {projectKey, sessionId} = extractPeerId(peerId); let {projectKey, sessionId} = extractPeerId(roomId);
if (projectKey !== undefined) { if (projectKey !== undefined) {
liveSessions[projectKey] = liveSessions[projectKey] || []; liveSessionsPerProject[projectKey] = liveSessionsPerProject[projectKey] || new Set();
if (hasFilters(filters)) { if (withFilters) {
const connected_sockets = await io.in(peerId).fetchSockets(); const connected_sockets = await io.in(roomId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo
&& isValidSession(item.handshake.query.sessionInfo, filters.filter)) { && isValidSession(item.handshake.query.sessionInfo, filters.filter)) {
liveSessions[projectKey].push(sessionId); liveSessionsPerProject[projectKey].add(sessionId);
} }
} }
} else { } else {
liveSessions[projectKey].push(sessionId); liveSessionsPerProject[projectKey].add(sessionId);
} }
} }
} }
let liveSessions = {};
liveSessionsPerProject.forEach((sessions, projectId) => {
liveSessions[projectId] = Array.from(sessions);
});
respond(res, liveSessions); respond(res, liveSessions);
} }
@ -74,35 +80,36 @@ const socketsListByProject = async function (req, res) {
let _projectKey = extractProjectKeyFromRequest(req); let _projectKey = extractProjectKeyFromRequest(req);
let _sessionId = extractSessionIdFromRequest(req); let _sessionId = extractSessionIdFromRequest(req);
let filters = await extractPayloadFromRequest(req); let filters = await extractPayloadFromRequest(req);
let liveSessions = {}; let withFilters = hasFilters(filters);
let liveSessions = new Set();
let rooms = await getAvailableRooms(io); let rooms = await getAvailableRooms(io);
for (let peerId of rooms.keys()) { for (let roomId of rooms.keys()) {
let {projectKey, sessionId} = extractPeerId(peerId); let {projectKey, sessionId} = extractPeerId(roomId);
if (projectKey === _projectKey && (_sessionId === undefined || _sessionId === sessionId)) { if (projectKey === _projectKey && (_sessionId === undefined || _sessionId === sessionId)) {
liveSessions[projectKey] = liveSessions[projectKey] || []; if (withFilters) {
if (hasFilters(filters)) { const connected_sockets = await io.in(roomId).fetchSockets();
const connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo
&& isValidSession(item.handshake.query.sessionInfo, filters.filter)) { && isValidSession(item.handshake.query.sessionInfo, filters.filter)) {
liveSessions[projectKey].push(sessionId); liveSessions.add(sessionId);
} }
} }
} else { } else {
liveSessions[projectKey].push(sessionId); liveSessions.add(sessionId);
} }
} }
} }
liveSessions[_projectKey] = liveSessions[_projectKey] || []; let sessions = Array.from(liveSessions);
respond(res, _sessionId === undefined ? sortPaginate(liveSessions[_projectKey], filters) respond(res, _sessionId === undefined ? sortPaginate(sessions, filters)
: liveSessions[_projectKey].length > 0 ? liveSessions[_projectKey][0] : sessions.length > 0 ? sessions[0]
: null); : null);
} }
const socketsLive = async function (req, res) { const socketsLive = async function (req, res) {
debug && console.log("[WS]looking for all available LIVE sessions"); debug && console.log("[WS]looking for all available LIVE sessions");
let filters = await extractPayloadFromRequest(req); let filters = await extractPayloadFromRequest(req);
let liveSessions = {}; let withFilters = hasFilters(filters);
let liveSessionsPerProject = {};
let rooms = await getAvailableRooms(io); let rooms = await getAvailableRooms(io);
for (let peerId of rooms.keys()) { for (let peerId of rooms.keys()) {
let {projectKey} = extractPeerId(peerId); let {projectKey} = extractPeerId(peerId);
@ -110,18 +117,22 @@ const socketsLive = async function (req, res) {
let connected_sockets = await io.in(peerId).fetchSockets(); let connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) { if (item.handshake.query.identity === IDENTITIES.session) {
liveSessions[projectKey] = liveSessions[projectKey] || []; liveSessionsPerProject[projectKey] = liveSessionsPerProject[projectKey] || new Set();
if (hasFilters(filters)) { if (withFilters) {
if (item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { 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 { } 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)); respond(res, sortPaginate(liveSessions, filters));
} }
@ -130,30 +141,36 @@ const socketsLiveByProject = async function (req, res) {
let _projectKey = extractProjectKeyFromRequest(req); let _projectKey = extractProjectKeyFromRequest(req);
let _sessionId = extractSessionIdFromRequest(req); let _sessionId = extractSessionIdFromRequest(req);
let filters = await extractPayloadFromRequest(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); let rooms = await getAvailableRooms(io);
for (let peerId of rooms.keys()) { for (let roomId of rooms.keys()) {
let {projectKey, sessionId} = extractPeerId(peerId); let {projectKey, sessionId} = extractPeerId(roomId);
if (projectKey === _projectKey && (_sessionId === undefined || _sessionId === sessionId)) { 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) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) { if (item.handshake.query.identity === IDENTITIES.session) {
liveSessions[projectKey] = liveSessions[projectKey] || []; if (withFilters) {
if (hasFilters(filters)) { if (item.handshake.query.sessionInfo &&
if (item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { isValidSession(item.handshake.query.sessionInfo, filters.filter) &&
liveSessions[projectKey].push(item.handshake.query.sessionInfo); !sessIDs.has(item.handshake.query.sessionInfo.sessionID)
) {
liveSessions.add(item.handshake.query.sessionInfo);
sessIDs.add(item.handshake.query.sessionInfo.sessionID);
} }
} else { } 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] || []; let sessions = Array.from(liveSessions);
respond(res, _sessionId === undefined ? sortPaginate(liveSessions[_projectKey], filters) respond(res, _sessionId === undefined ? sortPaginate(sessions, filters) : sessions.length > 0 ? sessions[0] : null);
: liveSessions[_projectKey].length > 0 ? liveSessions[_projectKey][0]
: null);
} }
const autocomplete = async function (req, res) { const autocomplete = async function (req, res) {
@ -178,10 +195,10 @@ const autocomplete = async function (req, res) {
respond(res, uniqueAutocomplete(results)); respond(res, uniqueAutocomplete(results));
} }
const findSessionSocketId = async (io, peerId) => { const findSessionSocketId = async (io, roomId, tabId) => {
const connected_sockets = await io.in(peerId).fetchSockets(); const connected_sockets = await io.in(roomId).fetchSockets();
for (let item of connected_sockets) { 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; return item.id;
} }
} }
@ -191,8 +208,8 @@ const findSessionSocketId = async (io, peerId) => {
async function sessions_agents_count(io, socket) { async function sessions_agents_count(io, socket) {
let c_sessions = 0, c_agents = 0; let c_sessions = 0, c_agents = 0;
const rooms = await getAvailableRooms(io); const rooms = await getAvailableRooms(io);
if (rooms.get(socket.peerId)) { if (rooms.get(socket.roomId)) {
const connected_sockets = await io.in(socket.peerId).fetchSockets(); const connected_sockets = await io.in(socket.roomId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) { 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) { async function get_all_agents_ids(io, socket) {
let agents = []; let agents = [];
const rooms = await getAvailableRooms(io); const rooms = await getAvailableRooms(io);
if (rooms.get(socket.peerId)) { if (rooms.get(socket.roomId)) {
const connected_sockets = await io.in(socket.peerId).fetchSockets(); const connected_sockets = await io.in(socket.roomId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.agent) { if (item.handshake.query.identity === IDENTITIES.agent) {
agents.push(item.id); agents.push(item.id);
@ -245,56 +262,74 @@ module.exports = {
socket.on(EVENTS_DEFINITION.listen.ERROR, err => errorHandler(EVENTS_DEFINITION.listen.ERROR, err)); 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)}`); debug && console.log(`WS started:${socket.id}, Query:${JSON.stringify(socket.handshake.query)}`);
socket._connectedAt = new Date(); socket._connectedAt = new Date();
let {projectKey: connProjectKey, sessionId: connSessionId, tabId:connTabId} = extractPeerId(socket.handshake.query.peerId);
socket.peerId = 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; 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); let {c_sessions, c_agents} = await sessions_agents_count(io, socket);
if (socket.identity === IDENTITIES.session) { if (socket.identity === IDENTITIES.session) {
if (c_sessions > 0) { if (c_sessions > 0) {
debug && console.log(`session already connected, refusing new connexion`); const rooms = await getAvailableRooms(io);
io.to(socket.id).emit(EVENTS_DEFINITION.emit.SESSION_ALREADY_CONNECTED); for (let roomId of rooms.keys()) {
return socket.disconnect(); 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); extractSessionInfo(socket);
if (c_agents > 0) { if (c_agents > 0) {
debug && console.log(`notifying new session about agent-existence`); debug && console.log(`notifying new session about agent-existence`);
let agents_ids = await get_all_agents_ids(io, socket); let agents_ids = await get_all_agents_ids(io, socket);
io.to(socket.id).emit(EVENTS_DEFINITION.emit.AGENTS_CONNECTED, agents_ids); 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) { } else if (c_sessions <= 0) {
debug && console.log(`notifying new agent about no SESSIONS with peerId:${socket.peerId}`); debug && console.log(`notifying new agent about no SESSIONS with peerId:${socket.peerId}`);
io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); 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); const rooms = await getAvailableRooms(io);
if (rooms.get(socket.peerId)) { if (rooms.get(socket.roomId)) {
debug && console.log(`${socket.id} joined room:${socket.peerId}, as:${socket.identity}, members:${rooms.get(socket.peerId).size}`); 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.identity === IDENTITIES.agent) {
if (socket.handshake.query.agentInfo !== undefined) { if (socket.handshake.query.agentInfo !== undefined) {
socket.handshake.query.agentInfo = JSON.parse(socket.handshake.query.agentInfo); 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 () => { 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) { 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"); debug && console.log("checking for number of connected agents and sessions");
let {c_sessions, c_agents} = await sessions_agents_count(io, socket); let {c_sessions, c_agents} = await sessions_agents_count(io, socket);
if (c_sessions === -1 && c_agents === -1) { 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) { if (c_sessions === 0) {
debug && console.log(`notifying everyone in ${socket.peerId} about no SESSIONS`); debug && console.log(`notifying everyone in ${socket.roomId} about no SESSIONS`);
socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.NO_SESSIONS);
} }
if (c_agents === 0) { if (c_agents === 0) {
debug && console.log(`notifying everyone in ${socket.peerId} about no AGENTS`); debug && console.log(`notifying everyone in ${socket.roomId} about no AGENTS`);
socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NO_AGENTS); socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.NO_AGENTS);
} }
}); });
@ -304,8 +339,25 @@ module.exports = {
debug && console.log('Ignoring update event.'); debug && console.log('Ignoring update event.');
return return
} }
socket.handshake.query.sessionInfo = {...socket.handshake.query.sessionInfo, ...args[0]}; // Back compatibility (add top layer with meta information)
socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.UPDATE_EVENT, args[0]); 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)); 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.`); debug && console.log(`received event:${eventName}, should be handled by another listener, stopping onAny.`);
return 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) { if (socket.identity === IDENTITIES.session) {
debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.peerId}`); debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.roomId}`);
socket.to(socket.peerId).emit(eventName, args[0]); // TODO: emit message to all agents in the room (except tabs)
socket.to(socket.roomId).emit(eventName, args[0]);
} else { } else {
debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to session of room:${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.peerId); let socketId = await findSessionSocketId(io, socket.roomId, args[0].meta.tabId);
if (socketId === null) { 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); io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS);
} else { } else {
debug && console.log("message sent"); debug && console.log("message sent");
@ -342,7 +399,7 @@ module.exports = {
const arr = Array.from(rooms); const arr = Array.from(rooms);
const filtered = arr.filter(room => !room[1].has(room[0])); const filtered = arr.filter(room => !room[1].has(room[0]));
for (let i of filtered) { for (let i of filtered) {
let {projectKey, sessionId} = extractPeerId(i[0]); let {projectKey, sessionId, tabId} = extractPeerId(i[0]);
if (projectKey !== null && sessionId !== null) { if (projectKey !== null && sessionId !== null) {
count++; count++;
} }

View file

@ -1,8 +1,22 @@
let PROJECT_KEY_LENGTH = parseInt(process.env.PROJECT_KEY_LENGTH) || 20; let PROJECT_KEY_LENGTH = parseInt(process.env.PROJECT_KEY_LENGTH) || 20;
let debug = process.env.debug === "1" || false; 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) => { const extractPeerId = (peerId) => {
let splited = peerId.split("-"); let splited = peerId.split("-");
if (splited.length !== 2) { if (splited.length < 2 || splited.length > 3) {
debug && console.error(`cannot split peerId: ${peerId}`); debug && console.error(`cannot split peerId: ${peerId}`);
return {}; return {};
} }
@ -10,7 +24,10 @@ const extractPeerId = (peerId) => {
debug && console.error(`wrong project key length for peerId: ${peerId}`); debug && console.error(`wrong project key length for peerId: ${peerId}`);
return {}; 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) => { const request_logger = (identity) => {
return (req, res, next) => { return (req, res, next) => {
@ -185,7 +202,7 @@ const sortPaginate = function (list, filters) {
list.sort((a, b) => { list.sort((a, b) => {
const tA = getValue(a, "timestamp"); const tA = getValue(a, "timestamp");
const tB = getValue(b, "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) { if (filters.sort.order) {
list.reverse(); list.reverse();
@ -246,6 +263,8 @@ const getCompressionConfig = function () {
} }
module.exports = { module.exports = {
transformFilters, transformFilters,
extractRoomId,
extractTabId,
extractPeerId, extractPeerId,
request_logger, request_logger,
getValidAttributes, getValidAttributes,

View file

@ -151,7 +151,7 @@ func main() {
} }
// Add message to dev buffer // 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 // Write message index
n, err = devBuffer.Write(messageIndex) n, err = devBuffer.Write(messageIndex)
if err != nil { if err != nil {

View file

@ -10,5 +10,5 @@ func IsIOSType(id int) bool {
} }
func IsDOMType(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
} }

View file

@ -81,6 +81,8 @@ const (
MsgMouseThrashing = 114 MsgMouseThrashing = 114
MsgUnbindNodes = 115 MsgUnbindNodes = 115
MsgResourceTiming = 116 MsgResourceTiming = 116
MsgTabChange = 117
MsgTabData = 118
MsgIssueEvent = 125 MsgIssueEvent = 125
MsgSessionEnd = 126 MsgSessionEnd = 126
MsgSessionSearch = 127 MsgSessionSearch = 127
@ -2163,6 +2165,48 @@ func (msg *ResourceTiming) TypeID() int {
return 116 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 { type IssueEvent struct {
message message
MessageID uint64 MessageID uint64

View file

@ -1314,6 +1314,24 @@ func DecodeResourceTiming(reader BytesReader) (Message, error) {
return msg, err 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) { func DecodeIssueEvent(reader BytesReader) (Message, error) {
var err error = nil var err error = nil
msg := &IssueEvent{} msg := &IssueEvent{}
@ -1927,6 +1945,10 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) {
return DecodeUnbindNodes(reader) return DecodeUnbindNodes(reader)
case 116: case 116:
return DecodeResourceTiming(reader) return DecodeResourceTiming(reader)
case 117:
return DecodeTabChange(reader)
case 118:
return DecodeTabData(reader)
case 125: case 125:
return DecodeIssueEvent(reader) return DecodeIssueEvent(reader)
case 126: case 126:

View file

@ -31,7 +31,7 @@ const pubClient = createClient({url: REDIS_URL});
const subClient = pubClient.duplicate(); const subClient = pubClient.duplicate();
console.log(`Using Redis: ${REDIS_URL}`); console.log(`Using Redis: ${REDIS_URL}`);
let io; let io;
const debug = process.env.debug === "1"; const debug = true;// = process.env.debug === "1";
const createSocketIOServer = function (server, prefix) { const createSocketIOServer = function (server, prefix) {
if (process.env.uws !== "true") { 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) { const uniqueSessions = function (data) {
let resArr = []; let resArr = [];
let resArrIDS = []; let resArrIDS = [];
@ -85,25 +86,30 @@ const respond = function (res, data) {
const socketsList = async function (req, res) { const socketsList = async function (req, res) {
debug && console.log("[WS]looking for all available sessions"); debug && console.log("[WS]looking for all available sessions");
let filters = await extractPayloadFromRequest(req, res); let filters = await extractPayloadFromRequest(req, res);
let liveSessions = {}; let withFilters = hasFilters(filters);
let liveSessionsPerProject = {};
let rooms = await getAvailableRooms(io); let rooms = await getAvailableRooms(io);
for (let peerId of rooms.keys()) { for (let peerId of rooms.keys()) {
let {projectKey, sessionId} = extractPeerId(peerId); let {projectKey, sessionId} = extractPeerId(peerId);
if (projectKey !== undefined) { if (projectKey !== undefined) {
liveSessions[projectKey] = liveSessions[projectKey] || []; liveSessionsPerProject[projectKey] = liveSessionsPerProject[projectKey] || new Set();
if (hasFilters(filters)) { if (withFilters) {
const connected_sockets = await io.in(peerId).fetchSockets(); const connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo
&& isValidSession(item.handshake.query.sessionInfo, filters.filter)) { && isValidSession(item.handshake.query.sessionInfo, filters.filter)) {
liveSessions[projectKey].push(sessionId); liveSessionsPerProject[projectKey].add(sessionId);
} }
} }
} else { } else {
liveSessions[projectKey].push(sessionId); liveSessionsPerProject[projectKey].add(sessionId);
} }
} }
} }
let liveSessions = {};
liveSessionsPerProject.forEach((sessions, projectId) => {
liveSessions[projectId] = Array.from(sessions);
});
respond(res, liveSessions); respond(res, liveSessions);
} }
@ -112,35 +118,37 @@ const socketsListByProject = async function (req, res) {
let _projectKey = extractProjectKeyFromRequest(req); let _projectKey = extractProjectKeyFromRequest(req);
let _sessionId = extractSessionIdFromRequest(req); let _sessionId = extractSessionIdFromRequest(req);
let filters = await extractPayloadFromRequest(req, res); let filters = await extractPayloadFromRequest(req, res);
let liveSessions = {}; let withFilters = hasFilters(filters);
let liveSessions = new Set();
let rooms = await getAvailableRooms(io); let rooms = await getAvailableRooms(io);
for (let peerId of rooms.keys()) { for (let peerId of rooms.keys()) {
let {projectKey, sessionId} = extractPeerId(peerId); let {projectKey, sessionId} = extractPeerId(peerId);
if (projectKey === _projectKey && (_sessionId === undefined || _sessionId === sessionId)) { if (projectKey === _projectKey && (_sessionId === undefined || _sessionId === sessionId)) {
liveSessions[projectKey] = liveSessions[projectKey] || []; if (withFilters) {
if (hasFilters(filters)) {
const connected_sockets = await io.in(peerId).fetchSockets(); const connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo
&& isValidSession(item.handshake.query.sessionInfo, filters.filter)) { && isValidSession(item.handshake.query.sessionInfo, filters.filter)) {
liveSessions[projectKey].push(sessionId); liveSessions.add(sessionId);
} }
} }
} else { } else {
liveSessions[projectKey].push(sessionId); liveSessions.add(sessionId);
} }
} }
} }
liveSessions[_projectKey] = liveSessions[_projectKey] || []; let sessions = Array.from(liveSessions);
respond(res, _sessionId === undefined ? sortPaginate(liveSessions[_projectKey], filters) respond(res, _sessionId === undefined ? sortPaginate(sessions, filters)
: liveSessions[_projectKey].length > 0 ? liveSessions[_projectKey][0] : sessions.length > 0 ? sessions[0]
: null); : null);
} }
const socketsLive = async function (req, res) { const socketsLive = async function (req, res) {
debug && console.log("[WS]looking for all available LIVE sessions"); debug && console.log("[WS]looking for all available LIVE sessions");
let filters = await extractPayloadFromRequest(req, res); let filters = await extractPayloadFromRequest(req, res);
let liveSessions = {}; let withFilters = hasFilters(filters);
let liveSessionsPerProject = {};
const sessIDs = new Set();
let rooms = await getAvailableRooms(io); let rooms = await getAvailableRooms(io);
for (let peerId of rooms.keys()) { for (let peerId of rooms.keys()) {
let {projectKey} = extractPeerId(peerId); let {projectKey} = extractPeerId(peerId);
@ -148,19 +156,31 @@ const socketsLive = async function (req, res) {
let connected_sockets = await io.in(peerId).fetchSockets(); let connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) { if (item.handshake.query.identity === IDENTITIES.session) {
liveSessions[projectKey] = liveSessions[projectKey] || []; liveSessionsPerProject[projectKey] = liveSessionsPerProject[projectKey] || new Set();
if (hasFilters(filters)) { if (withFilters) {
if (item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { if (item.handshake.query.sessionInfo &&
liveSessions[projectKey].push(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 { } 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)); respond(res, sortPaginate(liveSessions, filters));
} }
@ -169,7 +189,9 @@ const socketsLiveByProject = async function (req, res) {
let _projectKey = extractProjectKeyFromRequest(req); let _projectKey = extractProjectKeyFromRequest(req);
let _sessionId = extractSessionIdFromRequest(req); let _sessionId = extractSessionIdFromRequest(req);
let filters = await extractPayloadFromRequest(req, res); 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); let rooms = await getAvailableRooms(io);
for (let peerId of rooms.keys()) { for (let peerId of rooms.keys()) {
let {projectKey, sessionId} = extractPeerId(peerId); let {projectKey, sessionId} = extractPeerId(peerId);
@ -177,23 +199,28 @@ const socketsLiveByProject = async function (req, res) {
let connected_sockets = await io.in(peerId).fetchSockets(); let connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) { if (item.handshake.query.identity === IDENTITIES.session) {
liveSessions[projectKey] = liveSessions[projectKey] || []; if (withFilters) {
if (hasFilters(filters)) { if (item.handshake.query.sessionInfo &&
if (item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { isValidSession(item.handshake.query.sessionInfo, filters.filter) &&
liveSessions[projectKey].push(item.handshake.query.sessionInfo); !sessIDs.has(item.handshake.query.sessionInfo.sessionID)
) {
liveSessions.add(item.handshake.query.sessionInfo);
sessIDs.add(item.handshake.query.sessionInfo.sessionID);
} }
} else { } 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] || []; let sessions = Array.from(liveSessions);
respond(res, _sessionId === undefined ? sortPaginate(liveSessions[_projectKey], filters) respond(res, _sessionId === undefined ? sortPaginate(sessions, filters) : sessions.length > 0 ? sessions[0] : null);
: liveSessions[_projectKey].length > 0 ? liveSessions[_projectKey][0]
: null);
} }
const autocomplete = async function (req, res) { const autocomplete = async function (req, res) {
@ -218,10 +245,10 @@ const autocomplete = async function (req, res) {
respond(res, uniqueAutocomplete(results)); respond(res, uniqueAutocomplete(results));
} }
const findSessionSocketId = async (io, peerId) => { const findSessionSocketId = async (io, roomId, tabId) => {
const connected_sockets = await io.in(peerId).fetchSockets(); const connected_sockets = await io.in(roomId).fetchSockets();
for (let item of connected_sockets) { 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; return item.id;
} }
} }
@ -231,8 +258,8 @@ const findSessionSocketId = async (io, peerId) => {
async function sessions_agents_count(io, socket) { async function sessions_agents_count(io, socket) {
let c_sessions = 0, c_agents = 0; let c_sessions = 0, c_agents = 0;
const rooms = await getAvailableRooms(io); const rooms = await getAvailableRooms(io);
if (rooms.has(socket.peerId)) { if (rooms.has(socket.roomId)) {
const connected_sockets = await io.in(socket.peerId).fetchSockets(); const connected_sockets = await io.in(socket.roomId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) { 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) { async function get_all_agents_ids(io, socket) {
let agents = []; let agents = [];
const rooms = await getAvailableRooms(io); const rooms = await getAvailableRooms(io);
if (rooms.has(socket.peerId)) { if (rooms.has(socket.roomId)) {
const connected_sockets = await io.in(socket.peerId).fetchSockets(); const connected_sockets = await io.in(socket.roomId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.agent) { if (item.handshake.query.identity === IDENTITIES.agent) {
agents.push(item.id); agents.push(item.id);
@ -285,57 +312,76 @@ module.exports = {
socket.on(EVENTS_DEFINITION.listen.ERROR, err => errorHandler(EVENTS_DEFINITION.listen.ERROR, err)); 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)}`); debug && console.log(`WS started:${socket.id}, Query:${JSON.stringify(socket.handshake.query)}`);
socket._connectedAt = new Date(); socket._connectedAt = new Date();
let {projectKey: connProjectKey, sessionId: connSessionId, tabId:connTabId} = extractPeerId(socket.handshake.query.peerId);
socket.peerId = 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; 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); let {c_sessions, c_agents} = await sessions_agents_count(io, socket);
if (socket.identity === IDENTITIES.session) { if (socket.identity === IDENTITIES.session) {
if (c_sessions > 0) { if (c_sessions > 0) {
debug && console.log(`session already connected, refusing new connexion`); const rooms = await getAvailableRooms(io);
io.to(socket.id).emit(EVENTS_DEFINITION.emit.SESSION_ALREADY_CONNECTED); for (let roomId of rooms.keys()) {
return socket.disconnect(); 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); extractSessionInfo(socket);
if (c_agents > 0) { if (c_agents > 0) {
debug && console.log(`notifying new session about agent-existence`); debug && console.log(`notifying new session about agent-existence`);
let agents_ids = await get_all_agents_ids(io, socket); let agents_ids = await get_all_agents_ids(io, socket);
io.to(socket.id).emit(EVENTS_DEFINITION.emit.AGENTS_CONNECTED, agents_ids); 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) { } else if (c_sessions <= 0) {
debug && console.log(`notifying new agent about no SESSIONS with peerId:${socket.peerId}`); debug && console.log(`notifying new agent about no SESSIONS with peerId:${socket.peerId}`);
io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); 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); const rooms = await getAvailableRooms(io);
if (rooms.has(socket.peerId)) { if (rooms.has(socket.roomId)) {
let connectedSockets = await io.in(socket.peerId).fetchSockets(); let connectedSockets = await io.in(socket.roomId).fetchSockets();
debug && console.log(`${socket.id} joined room:${socket.peerId}, as:${socket.identity}, members:${connectedSockets.length}`); debug && console.log(`${socket.id} joined room:${socket.roomId}, as:${socket.identity}, members:${connectedSockets.length}`);
} }
if (socket.identity === IDENTITIES.agent) { if (socket.identity === IDENTITIES.agent) {
if (socket.handshake.query.agentInfo !== undefined) { if (socket.handshake.query.agentInfo !== undefined) {
socket.handshake.query.agentInfo = JSON.parse(socket.handshake.query.agentInfo); 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 () => { 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) { 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"); debug && console.log("checking for number of connected agents and sessions");
let {c_sessions, c_agents} = await sessions_agents_count(io, socket); let {c_sessions, c_agents} = await sessions_agents_count(io, socket);
if (c_sessions === -1 && c_agents === -1) { 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) { if (c_sessions === 0) {
debug && console.log(`notifying everyone in ${socket.peerId} about no SESSIONS`); debug && console.log(`notifying everyone in ${socket.roomId} about no SESSIONS`);
socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.NO_SESSIONS);
} }
if (c_agents === 0) { if (c_agents === 0) {
debug && console.log(`notifying everyone in ${socket.peerId} about no AGENTS`); debug && console.log(`notifying everyone in ${socket.roomId} about no AGENTS`);
socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NO_AGENTS); socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.NO_AGENTS);
} }
}); });
@ -345,8 +391,25 @@ module.exports = {
debug && console.log('Ignoring update event.'); debug && console.log('Ignoring update event.');
return return
} }
socket.handshake.query.sessionInfo = {...socket.handshake.query.sessionInfo, ...args[0]}; // Back compatibility (add top layer with meta information)
socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.UPDATE_EVENT, args[0]); 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)); 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.`); debug && console.log(`received event:${eventName}, should be handled by another listener, stopping onAny.`);
return 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) { if (socket.identity === IDENTITIES.session) {
debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.peerId}`); debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.roomId}`);
socket.to(socket.peerId).emit(eventName, args[0]); // TODO: send to all agents in the room
socket.to(socket.roomId).emit(eventName, args[0]);
} else { } else {
debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to session of room:${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.peerId); let socketId = await findSessionSocketId(io, socket.roomId, args[0].meta.tabId);
if (socketId === null) { 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); io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS);
} else { } else {
debug && console.log("message sent"); debug && console.log("message sent");

View file

@ -1,6 +1,7 @@
const _io = require('socket.io'); const _io = require('socket.io');
const express = require('express'); const express = require('express');
const { const {
extractRoomId,
extractPeerId, extractPeerId,
hasFilters, hasFilters,
isValidSession, isValidSession,
@ -26,7 +27,7 @@ const {
const wsRouter = express.Router(); const wsRouter = express.Router();
let io; let io;
const debug = process.env.debug === "1"; const debug = true;//process.env.debug === "1";
const createSocketIOServer = function (server, prefix) { const createSocketIOServer = function (server, prefix) {
if (process.env.uws !== "true") { if (process.env.uws !== "true") {
@ -67,25 +68,30 @@ const respond = function (res, data) {
const socketsList = async function (req, res) { const socketsList = async function (req, res) {
debug && console.log("[WS]looking for all available sessions"); debug && console.log("[WS]looking for all available sessions");
let filters = await extractPayloadFromRequest(req, res); let filters = await extractPayloadFromRequest(req, res);
let liveSessions = {}; let withFilters = hasFilters(filters);
let liveSessionsPerProject = {};
let rooms = await getAvailableRooms(io); let rooms = await getAvailableRooms(io);
for (let peerId of rooms.keys()) { for (let peerId of rooms.keys()) {
let {projectKey, sessionId} = extractPeerId(peerId); let {projectKey, sessionId} = extractPeerId(peerId);
if (projectKey !== undefined) { if (projectKey !== undefined) {
liveSessions[projectKey] = liveSessions[projectKey] || []; liveSessionsPerProject[projectKey] = liveSessionsPerProject[projectKey] || new Set();
if (hasFilters(filters)) { if (withFilters) {
const connected_sockets = await io.in(peerId).fetchSockets(); const connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo
&& isValidSession(item.handshake.query.sessionInfo, filters.filter)) { && isValidSession(item.handshake.query.sessionInfo, filters.filter)) {
liveSessions[projectKey].push(sessionId); liveSessionsPerProject[projectKey].add(sessionId);
} }
} }
} else { } else {
liveSessions[projectKey].push(sessionId); liveSessionsPerProject[projectKey].add(sessionId);
} }
} }
} }
let liveSessions = {};
liveSessionsPerProject.forEach((sessions, projectId) => {
liveSessions[projectId] = Array.from(sessions);
});
respond(res, liveSessions); respond(res, liveSessions);
} }
@ -94,35 +100,36 @@ const socketsListByProject = async function (req, res) {
let _projectKey = extractProjectKeyFromRequest(req); let _projectKey = extractProjectKeyFromRequest(req);
let _sessionId = extractSessionIdFromRequest(req); let _sessionId = extractSessionIdFromRequest(req);
let filters = await extractPayloadFromRequest(req, res); let filters = await extractPayloadFromRequest(req, res);
let liveSessions = {}; let withFilters = hasFilters(filters);
let liveSessions = new Set();
let rooms = await getAvailableRooms(io); let rooms = await getAvailableRooms(io);
for (let peerId of rooms.keys()) { for (let peerId of rooms.keys()) {
let {projectKey, sessionId} = extractPeerId(peerId); let {projectKey, sessionId} = extractPeerId(peerId);
if (projectKey === _projectKey && (_sessionId === undefined || _sessionId === sessionId)) { if (projectKey === _projectKey && (_sessionId === undefined || _sessionId === sessionId)) {
liveSessions[projectKey] = liveSessions[projectKey] || []; if (withFilters) {
if (hasFilters(filters)) {
const connected_sockets = await io.in(peerId).fetchSockets(); const connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo if (item.handshake.query.identity === IDENTITIES.session && item.handshake.query.sessionInfo
&& isValidSession(item.handshake.query.sessionInfo, filters.filter)) { && isValidSession(item.handshake.query.sessionInfo, filters.filter)) {
liveSessions[projectKey].push(sessionId); liveSessions.add(sessionId);
} }
} }
} else { } else {
liveSessions[projectKey].push(sessionId); liveSessions.add(sessionId);
} }
} }
} }
liveSessions[_projectKey] = liveSessions[_projectKey] || []; let sessions = Array.from(liveSessions);
respond(res, _sessionId === undefined ? sortPaginate(liveSessions[_projectKey], filters) respond(res, _sessionId === undefined ? sortPaginate(sessions, filters)
: liveSessions[_projectKey].length > 0 ? liveSessions[_projectKey][0] : sessions.length > 0 ? sessions[0]
: null); : null);
} }
const socketsLive = async function (req, res) { const socketsLive = async function (req, res) {
debug && console.log("[WS]looking for all available LIVE sessions"); debug && console.log("[WS]looking for all available LIVE sessions");
let filters = await extractPayloadFromRequest(req, res); let filters = await extractPayloadFromRequest(req, res);
let liveSessions = {}; let withFilters = hasFilters(filters);
let liveSessionsPerProject = {};
let rooms = await getAvailableRooms(io); let rooms = await getAvailableRooms(io);
for (let peerId of rooms.keys()) { for (let peerId of rooms.keys()) {
let {projectKey} = extractPeerId(peerId); let {projectKey} = extractPeerId(peerId);
@ -130,18 +137,22 @@ const socketsLive = async function (req, res) {
let connected_sockets = await io.in(peerId).fetchSockets(); let connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) { if (item.handshake.query.identity === IDENTITIES.session) {
liveSessions[projectKey] = liveSessions[projectKey] || []; liveSessionsPerProject[projectKey] = liveSessionsPerProject[projectKey] || new Set();
if (hasFilters(filters)) { if (hasFilters(filters)) {
if (item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { 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 { } 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)); respond(res, sortPaginate(liveSessions, filters));
} }
@ -150,7 +161,9 @@ const socketsLiveByProject = async function (req, res) {
let _projectKey = extractProjectKeyFromRequest(req); let _projectKey = extractProjectKeyFromRequest(req);
let _sessionId = extractSessionIdFromRequest(req); let _sessionId = extractSessionIdFromRequest(req);
let filters = await extractPayloadFromRequest(req, res); 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); let rooms = await getAvailableRooms(io);
for (let peerId of rooms.keys()) { for (let peerId of rooms.keys()) {
let {projectKey, sessionId} = extractPeerId(peerId); let {projectKey, sessionId} = extractPeerId(peerId);
@ -158,22 +171,26 @@ const socketsLiveByProject = async function (req, res) {
let connected_sockets = await io.in(peerId).fetchSockets(); let connected_sockets = await io.in(peerId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) { if (item.handshake.query.identity === IDENTITIES.session) {
liveSessions[projectKey] = liveSessions[projectKey] || []; if (withFilters) {
if (hasFilters(filters)) { if (item.handshake.query.sessionInfo &&
if (item.handshake.query.sessionInfo && isValidSession(item.handshake.query.sessionInfo, filters.filter)) { isValidSession(item.handshake.query.sessionInfo, filters.filter) &&
liveSessions[projectKey].push(item.handshake.query.sessionInfo); !sessIDs.has(item.handshake.query.sessionInfo.sessionID)
) {
liveSessions.add(item.handshake.query.sessionInfo);
sessIDs.add(item.handshake.query.sessionInfo.sessionID);
} }
} else { } 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] || []; let sessions = Array.from(liveSessions);
respond(res, _sessionId === undefined ? sortPaginate(liveSessions[_projectKey], filters) respond(res, _sessionId === undefined ? sortPaginate(sessions, filters) : sessions.length > 0 ? sessions[0] : null);
: liveSessions[_projectKey].length > 0 ? liveSessions[_projectKey][0]
: null);
} }
const autocomplete = async function (req, res) { const autocomplete = async function (req, res) {
@ -198,10 +215,10 @@ const autocomplete = async function (req, res) {
respond(res, uniqueAutocomplete(results)); respond(res, uniqueAutocomplete(results));
} }
const findSessionSocketId = async (io, peerId) => { const findSessionSocketId = async (io, roomId, tabId) => {
const connected_sockets = await io.in(peerId).fetchSockets(); const connected_sockets = await io.in(roomId).fetchSockets();
for (let item of connected_sockets) { 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; return item.id;
} }
} }
@ -211,8 +228,8 @@ const findSessionSocketId = async (io, peerId) => {
async function sessions_agents_count(io, socket) { async function sessions_agents_count(io, socket) {
let c_sessions = 0, c_agents = 0; let c_sessions = 0, c_agents = 0;
const rooms = await getAvailableRooms(io); const rooms = await getAvailableRooms(io);
if (rooms.get(socket.peerId)) { if (rooms.get(socket.roomId)) {
const connected_sockets = await io.in(socket.peerId).fetchSockets(); const connected_sockets = await io.in(socket.roomId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.session) { 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) { async function get_all_agents_ids(io, socket) {
let agents = []; let agents = [];
const rooms = await getAvailableRooms(io); const rooms = await getAvailableRooms(io);
if (rooms.get(socket.peerId)) { if (rooms.get(socket.roomId)) {
const connected_sockets = await io.in(socket.peerId).fetchSockets(); const connected_sockets = await io.in(socket.roomId).fetchSockets();
for (let item of connected_sockets) { for (let item of connected_sockets) {
if (item.handshake.query.identity === IDENTITIES.agent) { if (item.handshake.query.identity === IDENTITIES.agent) {
agents.push(item.id); agents.push(item.id);
@ -265,56 +282,75 @@ module.exports = {
socket.on(EVENTS_DEFINITION.listen.ERROR, err => errorHandler(EVENTS_DEFINITION.listen.ERROR, err)); 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)}`); debug && console.log(`WS started:${socket.id}, Query:${JSON.stringify(socket.handshake.query)}`);
socket._connectedAt = new Date(); socket._connectedAt = new Date();
let {projectKey: connProjectKey, sessionId: connSessionId, tabId:connTabId} = extractPeerId(socket.handshake.query.peerId);
socket.peerId = 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; 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); let {c_sessions, c_agents} = await sessions_agents_count(io, socket);
if (socket.identity === IDENTITIES.session) { if (socket.identity === IDENTITIES.session) {
if (c_sessions > 0) { if (c_sessions > 0) {
debug && console.log(`session already connected, refusing new connexion`); const rooms = await getAvailableRooms(io);
io.to(socket.id).emit(EVENTS_DEFINITION.emit.SESSION_ALREADY_CONNECTED); for (let roomId of rooms.keys()) {
return socket.disconnect(); 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); extractSessionInfo(socket);
if (c_agents > 0) { if (c_agents > 0) {
debug && console.log(`notifying new session about agent-existence`); debug && console.log(`notifying new session about agent-existence`);
let agents_ids = await get_all_agents_ids(io, socket); let agents_ids = await get_all_agents_ids(io, socket);
io.to(socket.id).emit(EVENTS_DEFINITION.emit.AGENTS_CONNECTED, agents_ids); 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) { } else if (c_sessions <= 0) {
debug && console.log(`notifying new agent about no SESSIONS with peerId:${socket.peerId}`); debug && console.log(`notifying new agent about no SESSIONS with peerId:${socket.peerId}`);
io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); 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); const rooms = await getAvailableRooms(io);
if (rooms.get(socket.peerId)) { if (rooms.get(socket.roomId)) {
debug && console.log(`${socket.id} joined room:${socket.peerId}, as:${socket.identity}, members:${rooms.get(socket.peerId).size}`); 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.identity === IDENTITIES.agent) {
if (socket.handshake.query.agentInfo !== undefined) { if (socket.handshake.query.agentInfo !== undefined) {
socket.handshake.query.agentInfo = JSON.parse(socket.handshake.query.agentInfo); 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 () => { 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) { 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"); debug && console.log("checking for number of connected agents and sessions");
let {c_sessions, c_agents} = await sessions_agents_count(io, socket); let {c_sessions, c_agents} = await sessions_agents_count(io, socket);
if (c_sessions === -1 && c_agents === -1) { 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) { if (c_sessions === 0) {
debug && console.log(`notifying everyone in ${socket.peerId} about no SESSIONS`); debug && console.log(`notifying everyone in ${socket.roomId} about no SESSIONS`);
socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); socket.to(socket.roomId).emit(EVENTS_DEFINITION.emit.NO_SESSIONS);
} }
if (c_agents === 0) { if (c_agents === 0) {
debug && console.log(`notifying everyone in ${socket.peerId} about no AGENTS`); 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.'); debug && console.log('Ignoring update event.');
return return
} }
socket.handshake.query.sessionInfo = {...socket.handshake.query.sessionInfo, ...args[0]}; // Back compatibility (add top layer with meta information)
socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.UPDATE_EVENT, args[0]); 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)); 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.`); debug && console.log(`received event:${eventName}, should be handled by another listener, stopping onAny.`);
return 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) { if (socket.identity === IDENTITIES.session) {
debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.peerId}`); 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 { } else {
debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to session of room:${socket.peerId}`); 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) { 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); io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS);
} else { } else {
debug && console.log("message sent"); debug && console.log("message sent");

View file

@ -71,7 +71,7 @@ class CreateDocument(Message):
__id__ = 7 __id__ = 7
def __init__(self, ): def __init__(self, ):
pass
class CreateElementNode(Message): class CreateElementNode(Message):
@ -759,6 +759,20 @@ class ResourceTiming(Message):
self.cached = cached 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): class IssueEvent(Message):
__id__ = 125 __id__ = 125

View file

@ -689,6 +689,16 @@ class MessageCodec(Codec):
cached=self.read_boolean(reader) 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: if message_id == 125:
return IssueEvent( return IssueEvent(
message_id=self.read_uint(reader), message_id=self.read_uint(reader),

View file

@ -24,9 +24,12 @@ function Controls(props: any) {
const { jumpToLive } = player; const { jumpToLive } = player;
const { const {
livePlay, livePlay,
logMarkedCountNow: logRedCount, currentTab,
exceptionsList, tabStates
} = store.get(); } = store.get();
const exceptionsList = tabStates[currentTab]?.exceptionsList || [];
const logRedCount = tabStates[currentTab]?.logMarkedCountNow || 0;
const showExceptions = exceptionsList.length > 0; const showExceptions = exceptionsList.length > 0;
const { const {
bottomBlock, bottomBlock,

View file

@ -1,39 +1,43 @@
import React from 'react'; import React from 'react';
import { Icon, Tooltip } from 'UI'; import { Icon, Tooltip } from 'UI';
import copy from 'copy-to-clipboard';
import { PlayerContext } from 'App/components/Session/playerContext'; import { PlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import Tab from 'Components/Session/Player/SharedComponents/Tab';
function SubHeader() { function SubHeader() {
const { store } = React.useContext(PlayerContext) const { store } = React.useContext(PlayerContext);
const { const { tabStates, currentTab, tabs } = store.get();
location: currentLocation,
} = store.get()
const [isCopied, setCopied] = React.useState(false);
const currentLocation = tabStates[currentTab]?.location || '';
const location = const location =
currentLocation !== undefined ? currentLocation.length > 60 currentLocation !== undefined
? `${currentLocation.slice(0, 60)}...` ? currentLocation.length > 70
: currentLocation : undefined; ? `${currentLocation.slice(0, 70)}...`
: currentLocation
: undefined;
return ( return (
<div className="w-full px-4 py-2 flex items-center border-b min-h-3"> <>
<div className="w-full px-4 pt-2 flex items-center border-b min-h-3">
{tabs.map((tab, i) => (
<React.Fragment key={tab}>
<Tab i={i} tab={tab} currentTab={tabs.length === 1 ? tab : currentTab} />
</React.Fragment>
))}
</div>
{location && ( {location && (
<div <div className={'w-full bg-white border-b border-gray-light'}>
className="flex items-center cursor-pointer color-gray-medium text-sm p-1 hover:bg-gray-light-shade rounded-md" <div className="flex w-fit items-center cursor-pointer color-gray-medium text-sm p-1">
onClick={() => { <Icon size="20" name="event/link" className="mr-1" />
copy(currentLocation || ''); <Tooltip title="Open in new tab" delay={0}>
setCopied(true); <a href={location} target="_blank">
setTimeout(() => setCopied(false), 5000); {location}
}} </a>
> </Tooltip>
<Icon size="20" name="event/link" className="mr-1" /> </div>
<Tooltip title={isCopied ? 'URL Copied to clipboard' : 'Click to copy'}>
{location}
</Tooltip>
</div> </div>
)} )}
</div> </>
); );
} }

View file

@ -25,13 +25,16 @@ function Overlay({
const { const {
messagesLoading, messagesLoading,
cssLoading,
peerConnectionStatus, peerConnectionStatus,
livePlay, livePlay,
calling, calling,
remoteControl, remoteControl,
recordingState, recordingState,
tabStates,
currentTab
} = store.get() } = store.get()
const cssLoading = tabStates[currentTab]?.cssLoading || false
const loading = messagesLoading || cssLoading const loading = messagesLoading || cssLoading
const liveStatusText = getStatusText(peerConnectionStatus) const liveStatusText = getStatusText(peerConnectionStatus)
const connectionStatus = peerConnectionStatus const connectionStatus = peerConnectionStatus

View file

@ -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 (
<div className={ cn(cls.main, 'flex flex-col w-full') } >
<div className="flex items-center w-full">
{ event.type && <Icon name={`event/${event.type.toLowerCase()}`} size="16" color={isClickrage? 'red' : 'gray-dark' } /> }
<div className="ml-3 w-full">
<div className="flex w-full items-first justify-between">
<div className="flex items-center w-full" style={{ minWidth: '0'}}>
<span className={cls.title}>{ title }</span>
{/* { body && !isLocation && <div className={ cls.data }>{ body }</div> } */}
{ body && !isLocation &&
<TextEllipsis maxWidth="60%" className="w-full ml-2 text-sm color-gray-medium" text={body} />
}
</div>
{ isLocation && event.speedIndex != null &&
<div className="color-gray-medium flex font-medium items-center leading-none justify-end">
<div className="font-size-10 pr-2">{"Speed Index"}</div>
<div>{ numberWithCommas(event.speedIndex || 0) }</div>
</div>
}
</div>
{ event.target && event.target.label &&
<div className={ cls.badge } >{ event.target.label }</div>
}
</div>
</div>
{ isLocation &&
<div className="mt-1">
<span className="text-sm font-normal color-gray-medium">{ body }</span>
</div>
}
</div>
);
};
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 (
<div
ref={ ref => { 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 &&
<button onClick={ this.copyHandler } className={ cls.contextMenu }>
{ event.target ? 'Copy CSS' : 'Copy URL' }
</button>
}
<div className={ cls.topBlock }>
<div className={ cls.firstLine }>
{ this.renderBody() }
</div>
{/* { event.type === TYPES.LOCATION &&
<div className="text-sm font-normal color-gray-medium">{event.url}</div>
} */}
</div>
{ event.type === TYPES.LOCATION && (event.fcpTime || event.visuallyComplete || event.timeToInteractive) &&
<LoadInfo
showInfo={ showLoadInfo }
onClick={ toggleLoadInfo }
event={ event }
prorata={ prorata({
parts: 100,
elements: { a: event.fcpTime, b: event.visuallyComplete, c: event.timeToInteractive },
startDivisorFn: elements => elements / 1.2,
// eslint-disable-next-line no-mixed-operators
divisorFn: (elements, parts) => elements / (2 * parts + 1),
}) }
/>
}
</div>
);
}
}

View file

@ -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 (
<div
className={cn(
stl.container,
'!py-1',
{
[stl.last]: isLastInGroup,
[stl.first]: event.type === TYPES.LOCATION,
[stl.dashAfter]: isLastInGroup && !isLastEvent,
},
isLastInGroup && '!pb-2',
event.type === TYPES.LOCATION && '!pt-2 !pb-2'
)}
>
{isFirst && isLocation && event.referrer && (
<div className={stl.referrer}>
<TextEllipsis>
Referrer: <span className={stl.url}>{safeRef}</span>
</TextEllipsis>
</div>
)}
{isNote ? (
<NoteEvent
note={event}
filterOutNote={filterOutNote}
onEdit={this.props.setEditNoteTooltip}
noEdit={this.props.currentUserId !== event.userId}
/>
) : isLocation ? (
<Event
extended={isFirst}
key={event.key}
event={event}
onClick={this.onEventClick}
selected={isSelected}
showLoadInfo={showLoadInfo}
toggleLoadInfo={this.toggleLoadInfo}
isCurrent={isCurrent}
presentInSearch={presentInSearch}
isLastInGroup={isLastInGroup}
whiteBg={whiteBg}
/>
) : (
<Event
key={event.key}
event={event}
onClick={this.onEventClick}
onCheckboxClick={this.onCheckboxClick}
selected={isSelected}
isCurrent={isCurrent}
showSelection={showSelection}
overlayed={isEditing}
presentInSearch={presentInSearch}
isLastInGroup={isLastInGroup}
whiteBg={whiteBg}
/>
)}
</div>
);
}
}
export default EventGroupWrapper

View file

@ -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<List>(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<HTMLInputElement>) => {
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 (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
rowIndex={index}
>
{({measure, registerChild}) => (
<div style={{ ...style, ...heightBug }} ref={registerChild}>
<EventGroupWrapper
query={query}
presentInSearch={eventsIndex.includes(index)}
isFirst={index==0}
mesureHeight={measure}
onEventClick={ onEventClick }
event={ event }
isLastEvent={ isLastEvent }
isLastInGroup={ isLastInGroup }
isCurrent={ isCurrent }
showSelection={ !playing }
isNote={isNote}
filterOutNote={filterOutNote}
/>
</div>
)}
</CellMeasurer>
);
}
const isEmptySearch = query && (usedEvents.length === 0 || !usedEvents)
return (
<>
<div className={ cn(styles.header, 'p-4') }>
<div className={ cn(styles.hAndProgress, 'mt-3') }>
<EventSearch
onChange={write}
setActiveTab={setActiveTab}
value={query}
header={
<div className="text-xl">User Events <span className="color-gray-medium">{ events.length }</span></div>
}
/>
</div>
</div>
<div
className={ cn("flex-1 px-4 pb-4", styles.eventsList) }
id="eventList"
data-openreplay-masked
onMouseOver={ onMouseOver }
onMouseLeave={ onMouseLeave }
>
{isEmptySearch && (
<div className='flex items-center'>
<Icon name="binoculars" size={18} />
<span className='ml-2'>No Matching Results</span>
</div>
)}
<AutoSizer disableWidth>
{({ height }) => (
<List
ref={scroller}
className={ styles.eventsList }
height={height + 10}
width={248}
overscanRowCount={6}
itemSize={230}
rowCount={usedEvents.length}
deferredMeasurementCache={cache}
rowHeight={cache.rowHeight}
rowRenderer={renderGroup}
scrollToAlignment="start"
/>
)}
</AutoSizer>
</div>
</>
);
}
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))

View file

@ -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 } }) => (
<div>
<div className={ styles.bar } onClick={ onClick }>
{ typeof fcpTime === 'number' && <div style={ { width: `${ a }%` } } /> }
{ typeof visuallyComplete === 'number' && <div style={ { width: `${ b }%` } } /> }
{ typeof timeToInteractive === 'number' && <div style={ { width: `${ c }%` } } /> }
</div>
<div className={ styles.bottomBlock } data-hidden={ !showInfo }>
{ typeof fcpTime === 'number' &&
<div className={ styles.wrapper }>
<div className={ styles.lines } />
<div className={ styles.label } >{ 'Time to Render' }</div>
<div className={ styles.value }>{ `${ numberWithCommas(fcpTime || 0) }ms` }</div>
</div>
}
{ typeof visuallyComplete === 'number' &&
<div className={ styles.wrapper }>
<div className={ styles.lines } />
<div className={ styles.label } >{ 'Visually Complete' }</div>
<div className={ styles.value }>{ `${ numberWithCommas(visuallyComplete || 0) }ms` }</div>
</div>
}
{ typeof timeToInteractive === 'number' &&
<div className={ styles.wrapper }>
<div className={ styles.lines } />
<div className={ styles.label } >{ 'Time To Interactive' }</div>
<div className={ styles.value }>{ `${ numberWithCommas(timeToInteractive || 0) }ms` }</div>
</div>
}
</div>
</div>
);
LoadInfo.displayName = 'LoadInfo';
export default LoadInfo;

View file

@ -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<string, any>) => 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}&note=${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 (
<div
className="flex items-start flex-col p-2 border rounded"
style={{ background: '#FFFEF5' }}
>
<div className="flex items-center w-full relative">
<div className="p-3 bg-gray-light rounded-full">
<Icon name="quotes" color="main" />
</div>
<div className="ml-2">
<div
className="text-base"
style={{
maxWidth: 150,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{props.note.userName}
</div>
<div className="text-disabled-text text-sm">
{formatTimeOrDate(props.note.createdAt as unknown as number, timezone)}
</div>
</div>
<div className="cursor-pointer absolute" style={{ right: -5 }}>
<ItemMenu bold items={menuItems} />
</div>
</div>
<div
className="text-base capitalize-first my-3 overflow-y-scroll overflow-x-hidden"
style={{ maxHeight: 200, maxWidth: 220 }}
>
{props.note.message}
</div>
<div>
<div className="flex items-center gap-2 flex-wrap w-full">
{props.note.tag ? (
<div
key={props.note.tag}
style={{
// @ts-ignore
background: tagProps[props.note.tag],
userSelect: 'none',
padding: '1px 6px',
}}
className="rounded-full text-white text-xs select-none w-fit"
>
{props.note.tag}
</div>
) : null}
{!props.note.isPublic ? null : <TeamBadge />}
</div>
</div>
</div>
);
}
export default observer(NoteEvent);

View file

@ -1 +0,0 @@
export { default } from './EventsBlock';

View file

@ -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 (
<div
key={tab}
style={{ marginBottom: '-2px' }}
onClick={() => 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}
</div>
);
}
export default Tab;

View file

@ -113,7 +113,7 @@ function BugReportModal({ hideModal, session, width, height, account, xrayProps,
// REQUIRED FOR FUTURE USAGE AND AS AN EXAMPLE OF THE FUNCTIONALITY // REQUIRED FOR FUTURE USAGE AND AS AN EXAMPLE OF THE FUNCTIONALITY
function buildPng() { function buildPng() {
html2canvas(reportRef.current, { html2canvas(reportRef.current!, {
scale: 2, scale: 2,
ignoreElements: (e) => e.id.includes('pdf-ignore'), ignoreElements: (e) => e.id.includes('pdf-ignore'),
}).then((canvas) => { }).then((canvas) => {
@ -147,11 +147,11 @@ function BugReportModal({ hideModal, session, width, height, account, xrayProps,
} }
function buildText() { function buildText() {
doc doc
.html(reportRef.current, { .html(reportRef.current!, {
x: 0, x: 0,
y: 0, y: 0,
width: 210, width: 210,
windowWidth: reportRef.current.getBoundingClientRect().width, windowWidth: reportRef.current!.getBoundingClientRect().width,
autoPaging: 'text', autoPaging: 'text',
html2canvas: { html2canvas: {
ignoreElements: (e) => e.id.includes('pdf-ignore') || e instanceof SVGElement, ignoreElements: (e) => e.id.includes('pdf-ignore') || e instanceof SVGElement,

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import cn from 'classnames'; import cn from 'classnames';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { TextEllipsis } from 'UI'; import { TextEllipsis, Icon } from 'UI';
import withToggle from 'HOCs/withToggle'; import withToggle from 'HOCs/withToggle';
import { TYPES } from 'Types/session/event'; import { TYPES } from 'Types/session/event';
import Event from './Event'; import Event from './Event';
@ -57,6 +57,7 @@ class EventGroupWrapper extends React.Component {
isFirst, isFirst,
presentInSearch, presentInSearch,
isNote, isNote,
isTabChange,
filterOutNote filterOutNote
} = this.props; } = this.props;
const isLocation = event.type === TYPES.LOCATION; const isLocation = event.type === TYPES.LOCATION;
@ -107,7 +108,7 @@ class EventGroupWrapper extends React.Component {
isLastInGroup={isLastInGroup} isLastInGroup={isLastInGroup}
whiteBg={true} whiteBg={true}
/> />
) : ( ) : isTabChange ? (<TabChange from={event.fromTab} to={event.toTab} />) : (
<Event <Event
key={event.key} key={event.key}
event={event} event={event}
@ -123,10 +124,24 @@ class EventGroupWrapper extends React.Component {
/> />
)} )}
</div> </div>
{isLastInGroup && <div className='border-t border-color-gray-light-shade' />} {(isLastInGroup && !isTabChange) && <div className='border-t border-color-gray-light-shade' />}
</> </>
); );
} }
} }
function TabChange({ from, to }) { return (
<div className={'text-center p-2 bg-gray-lightest w-full my-2 flex items-center gap-2 justify-center'}>
<span>Tab change:</span>
<span className={'font-semibold'}>
{from}
</span>
<Icon name={"arrow-right-short"} size={18} color={"gray-dark"}/>
<span className={'font-semibold'}>
{to}
</span>
</div>
)
}
export default EventGroupWrapper; export default EventGroupWrapper;

View file

@ -13,7 +13,7 @@ import { observer } from 'mobx-react-lite';
import { RootStore } from 'App/duck'; import { RootStore } from 'App/duck';
import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache'; import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache';
import { InjectedEvent } from 'Types/session/event'; import { InjectedEvent } from 'Types/session/event';
import Session from 'Types/session'; import Session, { mergeEventLists } from 'Types/session';
interface IProps { interface IProps {
setEventFilter: (filter: { query: string }) => void; setEventFilter: (filter: { query: string }) => void;
@ -29,14 +29,14 @@ interface IProps {
function EventsBlock(props: IProps) { function EventsBlock(props: IProps) {
const [mouseOver, setMouseOver] = React.useState(true); const [mouseOver, setMouseOver] = React.useState(true);
const scroller = React.useRef<List>(null); const scroller = React.useRef<List>(null);
const cache = useCellMeasurerCache( { const cache = useCellMeasurerCache({
fixedWidth: true, fixedWidth: true,
defaultHeight: 300, defaultHeight: 300,
}); });
const { store, player } = React.useContext(PlayerContext); const { store, player } = React.useContext(PlayerContext);
const { eventListNow, playing } = store.get(); const { playing, tabStates, tabChangeEvents } = store.get();
const { const {
filteredEvents, filteredEvents,
@ -44,12 +44,19 @@ function EventsBlock(props: IProps) {
filterOutNote, filterOutNote,
query, query,
setActiveTab, setActiveTab,
events, notesWithEvents = [],
notesWithEvents,
} = props; } = 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 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<HTMLInputElement>) => { const write = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
props.setEventFilter({ query: value }); props.setEventFilter({ query: value });
@ -110,6 +117,7 @@ function EventsBlock(props: IProps) {
const isLastInGroup = isLastEvent || usedEvents[index + 1]?.type === TYPES.LOCATION; const isLastInGroup = isLastEvent || usedEvents[index + 1]?.type === TYPES.LOCATION;
const event = usedEvents[index]; const event = usedEvents[index];
const isNote = 'noteId' in event; const isNote = 'noteId' in event;
const isTabChange = event.type === 'TABCHANGE';
const isCurrent = index === currentTimeEventIndex; const isCurrent = index === currentTimeEventIndex;
const heightBug = const heightBug =
@ -130,6 +138,7 @@ function EventsBlock(props: IProps) {
isCurrent={isCurrent} isCurrent={isCurrent}
showSelection={!playing} showSelection={!playing}
isNote={isNote} isNote={isNote}
isTabChange={isTabChange}
filterOutNote={filterOutNote} filterOutNote={filterOutNote}
/> />
</div> </div>

View file

@ -25,7 +25,8 @@ interface IProps {
function Exceptions({ errorStack, sourcemapUploaded, loading }: IProps) { function Exceptions({ errorStack, sourcemapUploaded, loading }: IProps) {
const { player, store } = React.useContext(PlayerContext); 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 [filter, setFilter] = React.useState('');
const [currentError, setCurrentErrorVal] = React.useState(null); const [currentError, setCurrentErrorVal] = React.useState(null);

View file

@ -14,7 +14,8 @@ function renderDefaultStatus() {
function GraphQL() { function GraphQL() {
const { player, store } = React.useContext(PlayerContext); 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 = { const defaultState = {
filter: '', filter: '',

View file

@ -25,15 +25,19 @@ function OverviewPanel({ issuesList }: { issuesList: Record<string, any>[] }) {
const { const {
endTime, endTime,
performanceChartData, currentTab,
stackList: stackEventList, tabStates,
eventList: eventsList,
frustrationsList,
exceptionsList,
resourceList: resourceListUnmap,
fetchList,
graphqlList,
} = store.get(); } = 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; const fetchPresented = fetchList.length > 0;
@ -50,7 +54,7 @@ function OverviewPanel({ issuesList }: { issuesList: Record<string, any>[] }) {
PERFORMANCE: performanceChartData, PERFORMANCE: performanceChartData,
FRUSTRATIONS: frustrationsList, FRUSTRATIONS: frustrationsList,
}; };
}, [dataLoaded]); }, [dataLoaded, currentTab]);
useEffect(() => { useEffect(() => {
if (dataLoaded) { if (dataLoaded) {
@ -67,7 +71,7 @@ function OverviewPanel({ issuesList }: { issuesList: Record<string, any>[] }) {
) { ) {
setDataLoaded(true); setDataLoaded(true);
} }
}, [resourceList, issuesList, exceptionsList, eventsList, stackEventList, performanceChartData]); }, [resourceList, issuesList, exceptionsList, eventsList, stackEventList, performanceChartData, currentTab]);
return ( return (
<React.Fragment> <React.Fragment>

View file

@ -21,6 +21,7 @@ import stl from './performance.module.css';
import BottomBlock from '../BottomBlock'; import BottomBlock from '../BottomBlock';
import InfoLine from '../BottomBlock/InfoLine'; import InfoLine from '../BottomBlock/InfoLine';
import { toJS } from "mobx";
const CPU_VISUAL_OFFSET = 10; const CPU_VISUAL_OFFSET = 10;
@ -183,17 +184,22 @@ function Performance({
const [_data, setData] = React.useState<any[]>([]) const [_data, setData] = React.useState<any[]>([])
const { const {
performanceChartTime,
performanceChartData,
connType, connType,
connBandwidth, connBandwidth,
performanceAvailability: availability, tabStates,
currentTab,
} = store.get(); } = store.get();
React.useState(() => { const {
performanceChartTime = [],
performanceChartData = [],
performanceAvailability: availability = {}
} = tabStates[currentTab];
React.useEffect(() => {
setTicks(generateTicks(performanceChartData)); setTicks(generateTicks(performanceChartData));
setData(addFpsMetadata(performanceChartData)); setData(addFpsMetadata(performanceChartData));
}) }, [currentTab])
const onDotClick = ({ index: pointer }: { index: number }) => { const onDotClick = ({ index: pointer }: { index: number }) => {

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import cn from 'classnames'; import cn from 'classnames';
import { connect } from 'react-redux'; 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 { PlayButton, PlayingState, FullScreenButton } from 'App/player-ui'
import { Icon, Tooltip } from 'UI'; import { Icon, Tooltip } from 'UI';
@ -67,17 +67,21 @@ function Controls(props: any) {
completed, completed,
skip, skip,
speed, speed,
cssLoading,
messagesLoading, messagesLoading,
inspectorMode, inspectorMode,
markedTargets, markedTargets,
exceptionsList, currentTab,
profilesList, tabStates
graphqlList,
logMarkedCountNow: logRedCount,
resourceMarkedCountNow: resourceRedCount,
stackMarkedCountNow: stackRedCount,
} = store.get(); } = 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 { const {
bottomBlock, bottomBlock,
toggleBottomBlock, toggleBottomBlock,
@ -86,10 +90,10 @@ function Controls(props: any) {
skipInterval, skipInterval,
disabledRedux, disabledRedux,
showStorageRedux, showStorageRedux,
session session,
} = props; } = 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 disabled = disabledRedux || cssLoading || messagesLoading || inspectorMode || markedTargets;
const profilesCount = profilesList.length; const profilesCount = profilesList.length;
const graphqlCount = graphqlList.length; const graphqlCount = graphqlList.length;

View file

@ -13,6 +13,7 @@ import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { DateTime, Duration } from 'luxon'; import { DateTime, Duration } from 'luxon';
import Issue from "Types/session/issue"; import Issue from "Types/session/issue";
import { toJS } from 'mobx'
function getTimelinePosition(value: number, scale: number) { function getTimelinePosition(value: number, scale: number) {
const pos = value * scale; const pos = value * scale;
@ -35,20 +36,21 @@ function Timeline(props: IProps) {
playing, playing,
time, time,
skipIntervals, skipIntervals,
eventList: events,
skip, skip,
skipToIssue, skipToIssue,
ready, ready,
endTime, endTime,
devtoolsLoading, devtoolsLoading,
domLoading, domLoading,
tabStates,
currentTab,
} = store.get() } = store.get()
const { issues } = props; const { issues } = props;
const notes = notesStore.sessionNotes const notes = notesStore.sessionNotes
const progressRef = useRef<HTMLDivElement>(null) const progressRef = useRef<HTMLDivElement>(null)
const timelineRef = useRef<HTMLDivElement>(null) const timelineRef = useRef<HTMLDivElement>(null)
const events = tabStates[currentTab]?.eventList || [];
const scale = 100 / endTime; const scale = 100 / endTime;

View file

@ -15,7 +15,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
height: 65px; height: 55px;
padding-left: 10px; padding-left: 10px;
padding-right: 0; padding-right: 0;
} }

View file

@ -22,13 +22,14 @@ function Overlay({
const { const {
playing, playing,
messagesLoading, messagesLoading,
cssLoading,
completed, completed,
autoplay, autoplay,
inspectorMode, inspectorMode,
markedTargets, markedTargets,
activeTargetIndex, activeTargetIndex,
tabStates,
} = store.get() } = store.get()
const cssLoading = Object.values(tabStates).some(({ cssLoading }) => cssLoading)
const loading = messagesLoading || cssLoading const loading = messagesLoading || cssLoading
const showAutoplayTimer = completed && autoplay && nextId const showAutoplayTimer = completed && autoplay && nextId

View file

@ -5,6 +5,7 @@ import { PlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { JSONTree, NoContent, Tooltip } from 'UI'; import { JSONTree, NoContent, Tooltip } from 'UI';
import { formatMs } from 'App/date'; import { formatMs } from 'App/date';
// @ts-ignore
import { diff } from 'deep-diff'; import { diff } from 'deep-diff';
import { STORAGE_TYPES, selectStorageList, selectStorageListNow, selectStorageType } from 'Player'; import { STORAGE_TYPES, selectStorageList, selectStorageListNow, selectStorageType } from 'Player';
import Autoscroll from '../Autoscroll'; import Autoscroll from '../Autoscroll';
@ -40,12 +41,28 @@ interface Props {
function Storage(props: Props) { function Storage(props: Props) {
const lastBtnRef = React.useRef<HTMLButtonElement>(); const lastBtnRef = React.useRef<HTMLButtonElement>();
const [showDiffs, setShowDiffs] = React.useState(false); const [showDiffs, setShowDiffs] = React.useState(false);
const { player, store } = React.useContext(PlayerContext); const [stateObject, setState] = React.useState({});
const state = store.get();
const listNow = selectStorageListNow(state); const { player, store } = React.useContext(PlayerContext);
const list = selectStorageList(state); const { tabStates, currentTab } = store.get()
const type = selectStorageType(state); 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 decodeMessage = (msg: any) => {
const decoded = {}; const decoded = {};
@ -84,7 +101,11 @@ function Storage(props: Props) {
focusNextButton(); focusNextButton();
}, [listNow]); }, [listNow]);
const renderDiff = (item: Record<string, any>, prevItem: Record<string, any>) => { const renderDiff = (item: Record<string, any>, prevItem?: Record<string, any>) => {
if (!showDiffs) {
return;
}
if (!prevItem) { if (!prevItem) {
// we don't have state before first action // we don't have state before first action
return <div style={{ flex: 3 }} className="p-1" />; return <div style={{ flex: 3 }} className="p-1" />;
@ -166,7 +187,7 @@ function Storage(props: Props) {
name = itemD.mutation.join(''); name = itemD.mutation.join('');
} }
if (src !== null && !showDiffs) { if (src !== null && !showDiffs && itemD.state) {
setShowDiffs(true); setShowDiffs(true);
} }
@ -182,7 +203,7 @@ function Storage(props: Props) {
) : ( ) : (
<> <>
{renderDiff(itemD, prevItemD)} {renderDiff(itemD, prevItemD)}
<div style={{ flex: 2 }} className="flex pl-10 pt-2"> <div style={{ flex: 2 }} className={cn("flex pt-2", showDiffs && 'pl-10')}>
<JSONTree <JSONTree
name={ensureString(name)} name={ensureString(name)}
src={src} src={src}
@ -218,17 +239,14 @@ function Storage(props: Props) {
const { hintIsHidden } = props; const { hintIsHidden } = props;
const showStore = type !== STORAGE_TYPES.MOBX;
return ( return (
<BottomBlock> <BottomBlock>
<BottomBlock.Header> <BottomBlock.Header>
{list.length > 0 && ( {list.length > 0 && (
<div className="flex w-full"> <div className="flex w-full">
{showStore && ( <h3 style={{ width: '25%', marginRight: 20 }} className="font-semibold">
<h3 style={{ width: '25%', marginRight: 20 }} className="font-semibold"> {'STATE'}
{'STATE'} </h3>
</h3>
)}
{showDiffs ? ( {showDiffs ? (
<h3 style={{ width: '39%' }} className="font-semibold"> <h3 style={{ width: '39%' }} className="font-semibold">
DIFFS DIFFS
@ -311,22 +329,17 @@ function Storage(props: Props) {
size="small" size="small"
show={list.length === 0} show={list.length === 0}
> >
{showStore && ( <div className="ph-10 scroll-y" style={{ width: '25%' }}>
<div className="ph-10 scroll-y" style={{ width: '25%' }}> {list.length === 0 ? (
{list.length === 0 ? ( <div className="color-gray-light font-size-16 mt-20 text-center">
<div className="color-gray-light font-size-16 mt-20 text-center"> {'Empty state.'}
{'Empty state.'} </div>
</div> ) : (
) : ( <JSONTree collapsed={2} src={stateObject}
<JSONTree collapsed={2} src={ />
listNow.length === 0 )}
? decodeMessage(list[0]).state </div>
: decodeMessage(listNow[listNow.length - 1]).state} <div className="flex" style={{ width: '75%' }}>
/>
)}
</div>
)}
<div className="flex" style={{ width: showStore ? '75%' : '100%' }}>
<Autoscroll className="ph-10"> <Autoscroll className="ph-10">
{decodedList.map((item: Record<string, any>, i: number) => {decodedList.map((item: Record<string, any>, i: number) =>
renderItem(item, i, i > 0 ? decodedList[i - 1] : undefined) renderItem(item, i, i > 0 ? decodedList[i - 1] : undefined)

View file

@ -11,152 +11,162 @@ import BugReportModal from './BugReport/BugReportModal';
import { PlayerContext } from 'App/components/Session/playerContext'; import { PlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import AutoplayToggle from 'Shared/AutoplayToggle'; 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) { function SubHeader(props) {
const localhostWarnKey = localhostWarn(props.siteId) const localhostWarnKey = localhostWarn(props.siteId);
const defaultLocalhostWarn = localStorage.getItem(localhostWarnKey) !== '1' const defaultLocalhostWarn = localStorage.getItem(localhostWarnKey) !== '1';
const [showWarningModal, setWarning] = React.useState(defaultLocalhostWarn); const [showWarningModal, setWarning] = React.useState(defaultLocalhostWarn);
const { player, store } = React.useContext(PlayerContext); const { player, store } = React.useContext(PlayerContext);
const { const { width, height, endTime, tabStates, currentTab, tabs } = store.get();
width,
height,
location: currentLocation,
fetchList,
graphqlList,
resourceList,
exceptionsList,
eventList: eventsList,
endTime,
} = store.get();
const enabledIntegration = useMemo(() => { const currentLocation = tabStates[currentTab]?.location || '';
const { integrations } = props; const resourceList = tabStates[currentTab]?.resourceList || [];
if (!integrations || !integrations.size) { const exceptionsList = tabStates[currentTab]?.exceptionsList || [];
return false; const eventsList = tabStates[currentTab]?.eventList || [];
} const graphqlList = tabStates[currentTab]?.graphqlList || [];
const fetchList = tabStates[currentTab]?.fetchList || [];
return integrations.some((i) => i.token);
})
const mappedResourceList = resourceList const enabledIntegration = useMemo(() => {
.filter((r) => r.isRed || r.isYellow) const { integrations } = props;
.concat(fetchList.filter((i) => parseInt(i.status) >= 400)) if (!integrations || !integrations.size) {
.concat(graphqlList.filter((i) => parseInt(i.status) >= 400)); return false;
}
const { showModal, hideModal } = useModal(); return integrations.some((i) => i.token);
});
const location = const mappedResourceList = resourceList
currentLocation && currentLocation.length > 70 .filter((r) => r.isRed || r.isYellow)
? `${currentLocation.slice(0, 25)}...${currentLocation.slice(-40)}` .concat(fetchList.filter((i) => parseInt(i.status) >= 400))
: currentLocation; .concat(graphqlList.filter((i) => parseInt(i.status) >= 400));
const showReportModal = () => { const { showModal, hideModal } = useModal();
player.pause();
const xrayProps = { const location =
currentLocation: currentLocation, currentLocation && currentLocation.length > 70
resourceList: mappedResourceList, ? `${currentLocation.slice(0, 25)}...${currentLocation.slice(-40)}`
exceptionsList: exceptionsList, : currentLocation;
eventsList: eventsList,
endTime: endTime, const showReportModal = () => {
player.pause();
const xrayProps = {
currentLocation: currentLocation,
resourceList: mappedResourceList,
exceptionsList: exceptionsList,
eventsList: eventsList,
endTime: endTime,
};
showModal(
<BugReportModal width={width} height={height} xrayProps={xrayProps} hideModal={hideModal} />,
{ right: true, width: 620 }
);
}; };
showModal(
<BugReportModal width={width} height={height} xrayProps={xrayProps} hideModal={hideModal} />,
{ right: true, width: 620 }
);
};
const showWarning = const showWarning =
location && /(localhost)|(127.0.0.1)|(0.0.0.0)/.test(location) && showWarningModal; location && /(localhost)|(127.0.0.1)|(0.0.0.0)/.test(location) && showWarningModal;
const closeWarning = () => { const closeWarning = () => {
localStorage.setItem(localhostWarnKey, '1') localStorage.setItem(localhostWarnKey, '1');
setWarning(false) setWarning(false);
} };
return ( return (
<div className="w-full px-4 py-2 flex items-center border-b relative">
{showWarning ? (
<div
className="px-3 py-1 border border-gray-light drop-shadow-md rounded bg-active-blue flex items-center justify-between"
style={{
zIndex: 999,
position: 'absolute',
left: '50%',
bottom: '-24px',
transform: 'translate(-50%, 0)',
fontWeight: 500,
}}
>
Some assets may load incorrectly on localhost.
<a
href="https://docs.openreplay.com/en/troubleshooting/session-recordings/#testing-in-localhost"
target="_blank"
rel="noreferrer"
className="link ml-1"
>
Learn More
</a>
<div className="py-1 ml-3 cursor-pointer" onClick={closeWarning}>
<Icon name="close" size={16} color="black" />
</div>
</div>
) : null}
{location && (
<> <>
<div <div className="w-full px-4 flex items-center border-b relative">
className="flex items-center cursor-pointer color-gray-medium text-sm p-1 hover:bg-active-blue hover:!underline rounded-md" {showWarning ? (
> <div
<Icon size="20" name="event/link" className="mr-1" /> className="px-3 py-1 border border-gray-light drop-shadow-md rounded bg-active-blue flex items-center justify-between"
<Tooltip title="Open in new tab" delay={0}> style={{
<a href={currentLocation} target='_blank'>{location}</a> zIndex: 999,
</Tooltip> position: 'absolute',
</div> left: '50%',
</> bottom: '-24px',
)} transform: 'translate(-50%, 0)',
<div fontWeight: 500,
className="ml-auto text-sm flex items-center color-gray-medium gap-2" }}
style={{ width: 'max-content' }} >
> Some assets may load incorrectly on localhost.
<Button icon="file-pdf" variant="text" onClick={showReportModal}> <a
Create Bug Report href="https://docs.openreplay.com/en/troubleshooting/session-recordings/#testing-in-localhost"
</Button> target="_blank"
<NotePopup /> rel="noreferrer"
{enabledIntegration && <Issues sessionId={props.sessionId} /> } className="link ml-1"
<SharePopup >
entity="sessions" Learn More
id={props.sessionId} </a>
showCopyLink={true} <div className="py-1 ml-3 cursor-pointer" onClick={closeWarning}>
trigger={ <Icon name="close" size={16} color="black" />
<div className="relative"> </div>
<Button icon="share-alt" variant="text" className="relative"> </div>
Share ) : null}
</Button> {tabs.map((tab, i) => (
</div> <React.Fragment key={tab}>
} <Tab
/> i={i}
<ItemMenu tab={tab}
items={[ currentTab={tabs.length === 1 ? tab : currentTab}
{ changeTab={(changeTo) => player.changeTab(changeTo)}
key: 1, />
component: <AutoplayToggle />, </React.Fragment>
}, ))}
{ <div
key: 2, className="ml-auto text-sm flex items-center color-gray-medium gap-2"
component: <Bookmark noMargin sessionId={props.sessionId} />, style={{ width: 'max-content' }}
}, >
]} <Button icon="file-pdf" variant="text" onClick={showReportModal}>
/> Create Bug Report
</Button>
<NotePopup />
{enabledIntegration && <Issues sessionId={props.sessionId} />}
<SharePopup
entity="sessions"
id={props.sessionId}
showCopyLink={true}
trigger={
<div className="relative">
<Button icon="share-alt" variant="text" className="relative">
Share
</Button>
</div>
}
/>
<ItemMenu
items={[
{
key: 1,
component: <AutoplayToggle />,
},
{
key: 2,
component: <Bookmark noMargin sessionId={props.sessionId} />,
},
]}
/>
<div> <div>
<QueueControls /> <QueueControls />
</div> </div>
</div> </div>
</div> </div>
); {location && (
<div className={'w-full bg-white border-b border-gray-light'}>
<div className="flex w-fit items-center cursor-pointer color-gray-medium text-sm p-1">
<Icon size="20" name="event/link" className="mr-1" />
<Tooltip title="Open in new tab" delay={0}>
<a href={currentLocation} target="_blank">
{location}
</a>
</Tooltip>
</div>
</div>
)}
</>
);
} }
export default connect((state) => ({ export default connect((state) => ({
siteId: state.getIn(['site', 'siteId']), siteId: state.getIn(['site', 'siteId']),
integrations: state.getIn([ 'issues', 'list' ]) integrations: state.getIn(['issues', 'list']),
}))(observer(SubHeader)); }))(observer(SubHeader));

View file

@ -13,6 +13,7 @@ import { useModal } from 'App/components/Modal';
import useAutoscroll, { getLastItemTime } from '../useAutoscroll'; import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter' import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter'
import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache' import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache'
import { toJS } from 'mobx'
const ALL = 'ALL'; const ALL = 'ALL';
const INFO = 'INFO'; const INFO = 'INFO';
@ -74,7 +75,9 @@ function ConsolePanel({ isLive }: { isLive: boolean }) {
const { player, store } = React.useContext(PlayerContext) const { player, store } = React.useContext(PlayerContext)
const jump = (t: number) => player.jump(t) 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 ? const list = isLive ?
useMemo(() => logListNow.concat(exceptionsListNow).sort((a, b) => a.time - b.time), useMemo(() => logListNow.concat(exceptionsListNow).sort((a, b) => a.time - b.time),
[logListNow.length, exceptionsListNow.length] [logListNow.length, exceptionsListNow.length]

View file

@ -151,11 +151,16 @@ function NetworkPanel({ startedAt }: { startedAt: number }) {
domContentLoadedTime, domContentLoadedTime,
loadTime, loadTime,
domBuildingTime, domBuildingTime,
fetchList, tabStates,
resourceList, currentTab
fetchListNow,
resourceListNow,
} = store.get() } = store.get()
const {
fetchList = [],
resourceList = [],
fetchListNow = [],
resourceListNow = []
} = tabStates[currentTab]
const { showModal } = useModal(); const { showModal } = useModal();
const [sortBy, setSortBy] = useState('time'); const [sortBy, setSortBy] = useState('time');
const [sortAscending, setSortAscending] = useState(true); const [sortAscending, setSortAscending] = useState(true);

View file

@ -15,8 +15,8 @@ const renderName = (p: any) => <TextEllipsis text={p.name} />;
function ProfilerPanel() { function ProfilerPanel() {
const { store } = React.useContext(PlayerContext) const { store } = React.useContext(PlayerContext)
const { tabStates, currentTab } = store.get()
const profiles = store.get().profilesList as any[] // TODO lest internal types const profiles = tabStates[currentTab].profilesList || [] as any[] // TODO lest internal types
const { showModal } = useModal(); const { showModal } = useModal();
const [ filter, onFilterChange ] = useInputState() const [ filter, onFilterChange ] = useInputState()

View file

@ -22,7 +22,12 @@ const TABS = TAB_KEYS.map((tab) => ({ text: tab, key: tab }))
function StackEventPanel() { function StackEventPanel() {
const { player, store } = React.useContext(PlayerContext) const { player, store } = React.useContext(PlayerContext)
const jump = (t: number) => player.jump(t) 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 { const {
sessionStore: { devTools }, sessionStore: { devTools },

View file

@ -38,7 +38,7 @@ export default class ListWalker<T extends Timed> {
if (this.list.length === 0) { if (this.list.length === 0) {
return null; return null;
} }
return this.list[ this.list.length - 1 ]; return this.list.slice(-1)[0];
} }
get current(): T | null { get current(): T | null {
@ -108,7 +108,7 @@ export default class ListWalker<T extends Timed> {
/** /**
* @returns last message with the time <= t. * @returns last message with the time <= t.
* Assumed that the current message is already handled so * Assumed that the current message is already handled so
* if pointer doesn't cahnge <null> is returned. * if pointer doesn't change <null> is returned.
*/ */
moveGetLast(t: number, index?: number): T | null { moveGetLast(t: number, index?: number): T | null {
let key: string = "time"; //TODO let key: string = "time"; //TODO
@ -130,6 +130,30 @@ export default class ListWalker<T extends Timed> {
return changed ? this.list[ this.p - 1 ] : null; 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 * Moves over the messages starting from the current+1 to the last one with the time <= t
* applying callback on each of them * applying callback on each of them

View file

@ -29,6 +29,7 @@ export default class MessageLoader {
private store: Store<State>, private store: Store<State>,
private messageManager: MessageManager, private messageManager: MessageManager,
private isClickmap: boolean, private isClickmap: boolean,
private uiErrorHandler?: { error: (msg: string) => void }
) {} ) {}
createNewParser(shouldDecrypt = true, file?: string, toggleStatus?: (isLoading: boolean) => void) { createNewParser(shouldDecrypt = true, file?: string, toggleStatus?: (isLoading: boolean) => void) {
@ -57,6 +58,9 @@ export default class MessageLoader {
this.messageManager._sortMessagesHack(sorted) this.messageManager._sortMessagesHack(sorted)
toggleStatus?.(false); toggleStatus?.(false);
this.messageManager.setMessagesLoading(false) this.messageManager.setMessagesLoading(false)
}).catch(e => {
console.error(e)
this.uiErrorHandler?.error('Error parsing file: ' + e.message)
}) })
} }

View file

@ -1,71 +1,59 @@
// @ts-ignore // @ts-ignore
import { Decoder } from "syncod"; import { Decoder } from 'syncod';
import logger from 'App/logger'; import logger from 'App/logger';
import { TYPES as EVENT_TYPES } from 'Types/session/event'; import type { Store, ILog } from 'Player';
import { Log } from 'Player';
import {
ResourceType,
getResourceFromResourceTiming,
getResourceFromNetworkRequest
} from 'Player'
import type { Store } from 'Player';
import ListWalker from '../common/ListWalker'; import ListWalker from '../common/ListWalker';
import PagesManager from './managers/PagesManager';
import MouseMoveManager from './managers/MouseMoveManager'; import MouseMoveManager from './managers/MouseMoveManager';
import PerformanceTrackManager from './managers/PerformanceTrackManager';
import WindowNodeCounter from './managers/WindowNodeCounter';
import ActivityManager from './managers/ActivityManager'; import ActivityManager from './managers/ActivityManager';
import { MouseThrashing, MType } from "./messages"; import { MouseThrashing, MType } from './messages';
import { isDOMType } from './messages/filters.gen'; import type { Message, MouseClick } from './messages';
import type {
Message,
SetPageLocation,
ConnectionInformation,
SetViewportSize,
SetViewportScroll,
MouseClick,
} from './messages';
import Lists, { INITIAL_STATE as LISTS_INITIAL_STATE, State as ListsState } from './Lists';
import Screen, { import Screen, {
INITIAL_STATE as SCREEN_INITIAL_STATE, INITIAL_STATE as SCREEN_INITIAL_STATE,
State as ScreenState, State as ScreenState,
} from './Screen/Screen'; } from './Screen/Screen';
import type { InitialLists } from './Lists' import type { InitialLists } from './Lists';
import type { PerformanceChartPoint } from './managers/PerformanceTrackManager';
import type { SkipInterval } from './managers/ActivityManager'; 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 { interface RawList {
performanceChartData: PerformanceChartPoint[], event: Record<string, any>[] & { tabId: string | null };
skipIntervals: SkipInterval[], frustrations: Record<string, any>[] & { tabId: string | null };
connType?: string, stack: Record<string, any>[] & { tabId: string | null };
connBandwidth?: number, exceptions: ILog[];
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,
} }
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.MouseMove,
MType.MouseClick, MType.MouseClick,
MType.CreateElementNode, MType.CreateElementNode,
@ -73,258 +61,208 @@ const visualChanges = [
MType.SetInputChecked, MType.SetInputChecked,
MType.SetViewportSize, MType.SetViewportSize,
MType.SetViewportScroll, MType.SetViewportScroll,
] ];
export default class MessageManager { export default class MessageManager {
static INITIAL_STATE: State = { static INITIAL_STATE: State = {
...SCREEN_INITIAL_STATE, ...SCREEN_INITIAL_STATE,
...LISTS_INITIAL_STATE, tabStates: {},
performanceChartData: [],
skipIntervals: [], skipIntervals: [],
error: false, error: false,
cssLoading: false,
ready: false, ready: false,
lastMessageTime: 0, lastMessageTime: 0,
firstVisualEvent: 0, firstVisualEvent: 0,
messagesProcessed: false, messagesProcessed: false,
messagesLoading: false, messagesLoading: false,
} currentTab: '',
tabs: [],
tabChangeEvents: [],
};
private locationEventManager: ListWalker<any>/*<LocationEvent>*/ = new ListWalker();
private locationManager: ListWalker<SetPageLocation> = new ListWalker();
private loadedLocationManager: ListWalker<SetPageLocation> = new ListWalker();
private connectionInfoManger: ListWalker<ConnectionInformation> = new ListWalker();
private performanceTrackManager: PerformanceTrackManager = new PerformanceTrackManager();
private windowNodeCounter: WindowNodeCounter = new WindowNodeCounter();
private clickManager: ListWalker<MouseClick> = new ListWalker(); private clickManager: ListWalker<MouseClick> = new ListWalker();
private mouseThrashingManager: ListWalker<MouseThrashing> = new ListWalker(); private mouseThrashingManager: ListWalker<MouseThrashing> = new ListWalker();
private activityManager: ActivityManager | null = null;
private resizeManager: ListWalker<SetViewportSize> = new ListWalker([]);
private pagesManager: PagesManager;
private mouseMoveManager: MouseMoveManager; private mouseMoveManager: MouseMoveManager;
private activeTabManager = new ActiveTabManager();
private scrollManager: ListWalker<SetViewportScroll> = new ListWalker();
public readonly decoder = new Decoder(); public readonly decoder = new Decoder();
private lists: Lists;
private activityManager: ActivityManager | null = null;
private readonly sessionStart: number; private readonly sessionStart: number;
private navigationStartOffset: number = 0;
private lastMessageTime: number = 0; private lastMessageTime: number = 0;
private firstVisualEventSet = false; private firstVisualEventSet = false;
public readonly tabs: Record<string, TabSessionManager> = {};
private tabChangeEvents: Record<string, number>[] = [];
private activeTab = '';
constructor( constructor(
private readonly session: any /*Session*/, private readonly session: Record<string, any>,
private readonly state: Store<State>, private readonly state: Store<State & { time: number }>,
private readonly screen: Screen, private readonly screen: Screen,
initialLists?: Partial<InitialLists>, private readonly initialLists?: Partial<InitialLists>,
private readonly uiErrorHandler?: { error: (error: string) => void, }, private readonly uiErrorHandler?: { error: (error: string) => void }
) { ) {
this.pagesManager = new PagesManager(screen, this.session.isMobile, this.setCSSLoading) this.mouseMoveManager = new MouseMoveManager(screen);
this.mouseMoveManager = new MouseMoveManager(screen) this.sessionStart = this.session.startedAt;
this.activityManager = new ActivityManager(this.session.duration.milliseconds); // only if not-live
this.sessionStart = this.session.startedAt
this.lists = new Lists(initialLists)
initialLists?.event?.forEach((e: Record<string, string>) => { // 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
} }
public getListsFullState = () => { public getListsFullState = () => {
return this.lists.getFullListsState() const fullState: Record<string, any> = {};
} 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<InitialLists>) { public updateLists(lists: RawList) {
Object.keys(lists).forEach((key: 'event' | 'stack' | 'exceptions') => { Object.keys(this.tabs).forEach((tab) => {
const currentList = this.lists.lists[key] this.tabs[tab]!.updateLists(lists);
lists[key]!.forEach(item => currentList.insert(item)) // 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<string, string>) => {
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[]) => { public _sortMessagesHack = (msgs: Message[]) => {
// @ts-ignore Hack for upet (TODO: fix ordering in one mutation in tracker(removes first)) Object.values(this.tabs).forEach((tab) => tab._sortMessagesHack(msgs));
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;
})
}
private waitingForFiles: boolean = false private waitingForFiles: boolean = false;
public onFileReadSuccess = () => { public onFileReadSuccess = () => {
const stateToUpdate : Partial<State>= {
performanceChartData: this.performanceTrackManager.chartData,
performanceAvailability: this.performanceTrackManager.availability,
...this.lists.getFullListsState(),
}
if (this.activityManager) { if (this.activityManager) {
this.activityManager.end() this.activityManager.end();
stateToUpdate.skipIntervals = this.activityManager.list this.state.update({ skipIntervals: this.activityManager.list });
} }
this.state.update(stateToUpdate) Object.values(this.tabs).forEach((tab) => tab.onFileReadSuccess?.());
} };
public onFileReadFailed = (e: any) => { public onFileReadFailed = (e: any) => {
logger.error(e) logger.error(e);
this.state.update({ error: true }) this.state.update({ error: true });
this.uiErrorHandler?.error('Error requesting a session file') this.uiErrorHandler?.error('Error requesting a session file');
} };
public onFileReadFinally = () => { public onFileReadFinally = () => {
this.waitingForFiles = false this.waitingForFiles = false;
this.state.update({ messagesProcessed: true }) this.state.update({ messagesProcessed: true });
} };
public startLoading = () => { public startLoading = () => {
this.waitingForFiles = true this.waitingForFiles = true;
this.state.update({ messagesProcessed: false }) this.state.update({ messagesProcessed: false });
this.setMessagesLoading(true) this.setMessagesLoading(true);
} };
resetMessageManagers() { resetMessageManagers() {
this.locationEventManager = new ListWalker();
this.locationManager = new ListWalker();
this.loadedLocationManager = new ListWalker();
this.connectionInfoManger = new ListWalker();
this.clickManager = 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.mouseMoveManager = new MouseMoveManager(this.screen);
this.activityManager = new ActivityManager(this.session.duration.milliseconds); 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 { move(t: number): any {
const stateToUpdate: Partial<State> = {}; // usually means waiting for messages from live session
/* == REFACTOR_ME == */ if (Object.keys(this.tabs).length === 0) return;
const lastLoadedLocationMsg = this.loadedLocationManager.moveGetLast(t, index); this.activeTabManager.moveReady(t).then((tabId) => {
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);
}
// Moving mouse and setting :hover classes on ready view // Moving mouse and setting :hover classes on ready view
this.mouseMoveManager.move(t); this.mouseMoveManager.move(t);
const lastClick = this.clickManager.moveGetLast(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(); this.screen.cursor.click();
} }
const lastThrashing = this.mouseThrashingManager.moveGetLast(t) const lastThrashing = this.mouseThrashingManager.moveGetLast(t);
if (!!lastThrashing && t - lastThrashing.time < 300) { if (!!lastThrashing && t - lastThrashing.time < 300) {
this.screen.cursor.shake(); this.screen.cursor.shake();
} }
})
if (this.waitingForFiles && this.lastMessageTime <= t && t !== this.session.duration.milliseconds) { const activeTabs = this.state.get().tabs;
this.setMessagesLoading(true) 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 => { public updateChangeEvents() {
const lastMessageTime = Math.max(msg.time, this.lastMessageTime) this.state.update({ tabChangeEvents: this.tabChangeEvents });
this.lastMessageTime = lastMessageTime }
this.state.update({ lastMessageTime })
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)) { if (visualChanges.includes(msg.tp)) {
this.activityManager?.updateAcctivity(msg.time); this.activityManager?.updateAcctivity(msg.time);
} }
switch (msg.tp) { switch (msg.tp) {
case MType.SetPageLocation: case MType.TabChange:
this.locationManager.append(msg); const prevChange = this.activeTabManager.last;
if (msg.navigationStart > 0) { if (!prevChange || prevChange.tabId !== msg.tabId) {
this.loadedLocationManager.append(msg); 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; break;
case MType.SetViewportSize:
this.resizeManager.append(msg);
break;
case MType.MouseThrashing: case MType.MouseThrashing:
this.mouseThrashingManager.append(msg); this.mouseThrashingManager.append(msg);
break; break;
@ -334,103 +272,37 @@ export default class MessageManager {
case MType.MouseClick: case MType.MouseClick:
this.clickManager.append(msg); this.clickManager.append(msg);
break; 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: default:
switch (msg.tp) { switch (msg.tp) {
case MType.CreateDocument: case MType.CreateDocument:
if (!this.firstVisualEventSet) { 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.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) this.tabs[msg.tabId].distributeMessage(msg);
isDOMType(msg.tp) && this.pagesManager.appendMessage(msg)
break; break;
} }
} };
setMessagesLoading = (messagesLoading: boolean) => { setMessagesLoading = (messagesLoading: boolean) => {
if (!messagesLoading) {
this.updateChangeEvents();
}
this.screen.display(!messagesLoading); this.screen.display(!messagesLoading);
this.state.update({ messagesLoading, ready: !messagesLoading && !this.state.get().cssLoading }); this.state.update({ messagesLoading, ready: !messagesLoading && !this.state.get().cssLoading });
} };
decodeMessage(msg: Message) { 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.screen.scale({ height, width });
this.state.update({ width, height }); this.state.update({ width, height });
} }
@ -438,8 +310,15 @@ export default class MessageManager {
// TODO: clean managers? // TODO: clean managers?
clean() { clean() {
this.state.update(MessageManager.INITIAL_STATE); this.state.update(MessageManager.INITIAL_STATE);
// @ts-ignore
this.pagesManager.reset();
} }
}
function mapTabs(tabs: Record<string, TabState>) {
const tabIds = Object.keys(tabs);
const tabMap = {};
tabIds.forEach((tabId) => {
tabMap[tabId] = `Tab ${tabIds.indexOf(tabId)+1}`;
});
return tabMap;
} }

View file

@ -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<any>/*<LocationEvent>*/ = new ListWalker();
private locationManager: ListWalker<SetPageLocation> = new ListWalker();
private loadedLocationManager: ListWalker<SetPageLocation> = new ListWalker();
private connectionInfoManger: ListWalker<ConnectionInformation> = new ListWalker();
private performanceTrackManager: PerformanceTrackManager = new PerformanceTrackManager();
private windowNodeCounter: WindowNodeCounter = new WindowNodeCounter();
private resizeManager: ListWalker<SetViewportSize> = new ListWalker([]);
private pagesManager: PagesManager;
private scrollManager: ListWalker<SetViewportScroll> = 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<InitialLists>,
) {
this.pagesManager = new PagesManager(screen, this.session.isMobile, this.setCSSLoading)
this.lists = new Lists(initialLists)
initialLists?.event?.forEach((e: Record<string, string>) => { // TODO: to one of "Movable" module
if (e.type === EVENT_TYPES.LOCATION) {
this.locationEventManager.append(e);
}
})
}
public updateLists(lists: Partial<InitialLists>) {
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<string, string>) => {
if (e.type === EVENT_TYPES.LOCATION) {
this.locationEventManager.append(e);
}
})
this.updateLocalState({ ...this.lists.getFullListsState() });
}
updateLocalState(state: Partial<TabState>) {
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<string, any> = {};
/* == 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<Record<string,any>> = {
performanceChartData: this.performanceTrackManager.chartData,
performanceAvailability: this.performanceTrackManager.availability,
...this.lists.getFullListsState(),
}
this.updateLocalState(stateToUpdate)
}
public getListsFullState = () => {
return this.lists.getFullListsState()
}
clean() {
this.pagesManager.reset()
}
}

View file

@ -53,7 +53,8 @@ export default class WebPlayer extends Player {
session, session,
wpState, wpState,
messageManager, messageManager,
isClickMap isClickMap,
uiErrorHandler
) )
super(wpState, messageManager) super(wpState, messageManager)
this.screen = screen this.screen = screen
@ -82,7 +83,7 @@ export default class WebPlayer extends Player {
} }
updateLists = (session: any) => { updateLists = (session: any) => {
let lists = { const lists = {
event: session.events || [], event: session.events || [],
frustrations: session.frustrations || [], frustrations: session.frustrations || [],
stack: session.stackEvents || [], stack: session.stackEvents || [],
@ -162,6 +163,10 @@ export default class WebPlayer extends Player {
this.screen.cursor.showTag(name) this.screen.cursor.showTag(name)
} }
changeTab = (tab: string) => {
this.messageManager.changeTab(tab)
}
clean = () => { clean = () => {
super.clean() super.clean()
this.screen.clean() this.screen.clean()

View file

@ -166,9 +166,10 @@ export default class AssistManager {
waitingForMessages = true waitingForMessages = true
this.setStatus(ConnectionStatus.WaitingMessages) // TODO: reconnect happens frequently on bad network 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) { if (waitingForMessages) {
waitingForMessages = false // TODO: more explicit waitingForMessages = false // TODO: more explicit
this.setStatus(ConnectionStatus.Connected) this.setStatus(ConnectionStatus.Connected)
@ -185,9 +186,15 @@ export default class AssistManager {
this.setStatus(ConnectionStatus.Connected) 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.clearDisconnectTimeout()
!this.inactiveTimeout && this.setStatus(ConnectionStatus.Connected) !this.inactiveTimeout && this.setStatus(ConnectionStatus.Connected)
if (tabId !== currentTab) {
this.store.update({ currentTab: tabId })
}
if (typeof active === "boolean") { if (typeof active === "boolean") {
this.clearInactiveTimeout() this.clearInactiveTimeout()
if (active) { if (active) {

View file

@ -18,6 +18,7 @@ export enum CallingState {
export interface State { export interface State {
calling: CallingState; calling: CallingState;
currentTab?: string;
} }
export default class Call { export default class Call {
@ -158,7 +159,7 @@ export default class Call {
} }
initiateCallEnd = async () => { 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() this.handleCallEnd()
// TODO: We have it separated, right? (check) // TODO: We have it separated, right? (check)
// const remoteControl = this.store.get().remoteControl // 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: { private callArgs: {
localStream: LocalStream, localStream: LocalStream,
@ -206,7 +211,7 @@ export default class Call {
toggleVideoLocalStream(enabled: boolean) { toggleVideoLocalStream(enabled: boolean) {
this.getPeer().then((peer) => { 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 } if (![CallingState.NoCall, CallingState.Reconnecting].includes(this.store.get().calling)) { return }
this.store.update({ calling: CallingState.Connecting }) this.store.update({ calling: CallingState.Connecting })
this._peerConnection(this.peerID); 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) { private async _peerConnection(remotePeerId: string) {

View file

@ -12,6 +12,7 @@ export enum RemoteControlStatus {
export interface State { export interface State {
annotating: boolean annotating: boolean
remoteControl: RemoteControlStatus remoteControl: RemoteControlStatus
currentTab?: string
} }
export default class RemoteControl { export default class RemoteControl {
@ -28,11 +29,11 @@ export default class RemoteControl {
private agentInfo: Object, private agentInfo: Object,
private onToggle: (active: boolean) => void, private onToggle: (active: boolean) => void,
){ ){
socket.on("control_granted", id => { socket.on("control_granted", ({ meta, data }) => {
this.toggleRemoteControl(id === socket.id) this.toggleRemoteControl(data === socket.id)
}) })
socket.on("control_rejected", id => { socket.on("control_rejected", ({ meta, data }) => {
id === socket.id && this.toggleRemoteControl(false) data === socket.id && this.toggleRemoteControl(false)
this.onReject() this.onReject()
}) })
socket.on('SESSION_DISCONNECTED', () => { socket.on('SESSION_DISCONNECTED', () => {
@ -50,14 +51,18 @@ export default class RemoteControl {
private onMouseMove = (e: MouseEvent): void => { private onMouseMove = (e: MouseEvent): void => {
const data = this.screen.getInternalCoordinates(e) 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 => { private onWheel = (e: WheelEvent): void => {
e.preventDefault() e.preventDefault()
//throttling makes movements less smooth, so it is omitted //throttling makes movements less smooth, so it is omitted
//this.onMouseMove(e) //this.onMouseMove(e)
this.socket.emit("scroll", [ e.deltaX, e.deltaY ]) this.emitData("scroll", [ e.deltaX, e.deltaY ])
} }
public setCallbacks = ({ onReject }: { onReject: () => void }) => { public setCallbacks = ({ onReject }: { onReject: () => void }) => {
@ -76,9 +81,9 @@ export default class RemoteControl {
if (el instanceof HTMLTextAreaElement if (el instanceof HTMLTextAreaElement
|| el instanceof HTMLInputElement || el instanceof HTMLInputElement
) { ) {
this.socket && this.socket.emit("input", el.value) this.socket && this.emitData("input", el.value)
} else if (el.isContentEditable) { } 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 // TODO: send "focus" event to assist with the nodeID
@ -92,7 +97,7 @@ export default class RemoteControl {
el.onblur = null el.onblur = null
} }
} }
this.socket.emit("click", [ data.x, data.y ]); this.emitData("click", [ data.x, data.y ]);
} }
private toggleRemoteControl(enable: boolean){ private toggleRemoteControl(enable: boolean){
@ -116,17 +121,17 @@ export default class RemoteControl {
if (remoteControl === RemoteControlStatus.Requesting) { return } if (remoteControl === RemoteControlStatus.Requesting) { return }
if (remoteControl === RemoteControlStatus.Disabled) { if (remoteControl === RemoteControlStatus.Disabled) {
this.store.update({ remoteControl: RemoteControlStatus.Requesting }) this.store.update({ remoteControl: RemoteControlStatus.Requesting })
this.socket.emit("request_control", JSON.stringify({ this.emitData("request_control", JSON.stringify({
...this.agentInfo, ...this.agentInfo,
query: document.location.search query: document.location.search
})) }))
} else { } else {
this.releaseRemoteControl() this.releaseRemoteControl()
} }
} }
releaseRemoteControl = () => { releaseRemoteControl = () => {
this.socket.emit("release_control") this.emitData("release_control",)
this.toggleRemoteControl(false) this.toggleRemoteControl(false)
} }
@ -134,30 +139,30 @@ export default class RemoteControl {
toggleAnnotation(enable?: boolean) { toggleAnnotation(enable?: boolean) {
if (typeof enable !== "boolean") { if (typeof enable !== "boolean") {
enable = !!this.store.get().annotating enable = this.store.get().annotating
} }
if (enable && !this.annot) { if (enable && !this.annot) {
const annot = this.annot = new AnnotationCanvas() const annot = this.annot = new AnnotationCanvas()
annot.mount(this.screen.overlay) annot.mount(this.screen.overlay)
annot.canvas.addEventListener("mousedown", e => { annot.canvas.addEventListener("mousedown", e => {
const data = this.screen.getInternalViewportCoordinates(e) const data = this.screen.getInternalViewportCoordin1ates(e)
annot.start([ data.x, data.y ]) 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.canvas.addEventListener("mouseleave", () => {
annot.stop() annot.stop()
this.socket.emit("stopAnnotation") this.emitData("stopAnnotation")
}) })
annot.canvas.addEventListener("mouseup", () => { annot.canvas.addEventListener("mouseup", () => {
annot.stop() annot.stop()
this.socket.emit("stopAnnotation") this.emitData("stopAnnotation")
}) })
annot.canvas.addEventListener("mousemove", e => { annot.canvas.addEventListener("mousemove", e => {
if (!annot.isPainting()) { return } if (!annot.isPainting()) { return }
const data = this.screen.getInternalViewportCoordinates(e) const data = this.screen.getInternalViewportCoordinates(e)
annot.move([ data.x, data.y ]) 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 }) this.store.update({ annotating: true })
} else if (!enable && !!this.annot) { } else if (!enable && !!this.annot) {

View file

@ -10,6 +10,7 @@ export enum SessionRecordingStatus {
export interface State { export interface State {
recordingState: SessionRecordingStatus; recordingState: SessionRecordingStatus;
currentTab?: string;
} }
export default class ScreenRecording { export default class ScreenRecording {
@ -46,14 +47,19 @@ export default class ScreenRecording {
if (recordingState === SessionRecordingStatus.Requesting) return; if (recordingState === SessionRecordingStatus.Requesting) return;
this.store.update({ recordingState: SessionRecordingStatus.Requesting }) this.store.update({ recordingState: SessionRecordingStatus.Requesting })
this.socket.emit("request_recording", JSON.stringify({ this.emitData("request_recording", JSON.stringify({
...this.agentInfo, ...this.agentInfo,
query: document.location.search, query: document.location.search,
})) })
)
}
private emitData = (event: string, data?: any) => {
this.socket.emit(event, { meta: { tabId: this.store.get().currentTab }, data })
} }
stopRecording = () => { stopRecording = () => {
this.socket.emit("stop_recording") this.emitData("stop_recording")
this.toggleRecording(false) this.toggleRecording(false)
} }

View file

@ -0,0 +1,18 @@
import ListWalker from '../../common/ListWalker';
import type { TabChange } from '../messages';
export default class ActiveTabManager extends ListWalker<TabChange> {
currentTime = 0;
moveReady(t: number): Promise<string | null> {
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)
}
}

View file

@ -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) // 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 const skipIndexes = this.readCustomIndex(this.buf.slice(0, 8)) === 72057594037927940
|| this.readCustomIndex(this.buf.slice(0, 9)) === 72057594037927940 || this.readCustomIndex(this.buf.slice(0, 9)) === 72057594037927940
if (skipIndexes) { if (skipIndexes) {
this.noIndexes = true this.noIndexes = true
this.skip(8) this.skip(8)
@ -63,6 +64,7 @@ export default class MFileReader extends RawMessageReader {
} }
} }
currentTab = 'back-compatability'
readNext(): Message & { _index?: number } | null { readNext(): Message & { _index?: number } | null {
if (this.error || !this.hasNextByte()) { if (this.error || !this.hasNextByte()) {
return null return null
@ -82,6 +84,10 @@ export default class MFileReader extends RawMessageReader {
return null return null
} }
if (rMsg.tp === MType.TabData) {
this.currentTab = rMsg.tabId
return this.readNext()
}
if (rMsg.tp === MType.Timestamp) { if (rMsg.tp === MType.Timestamp) {
if (!this.startTime) { if (!this.startTime) {
this.startTime = rMsg.timestamp this.startTime = rMsg.timestamp
@ -93,6 +99,7 @@ export default class MFileReader extends RawMessageReader {
const index = this.noIndexes ? 0 : this.getLastMessageID() const index = this.noIndexes ? 0 : this.getLastMessageID()
const msg = Object.assign(rewriteMessage(rMsg), { const msg = Object.assign(rewriteMessage(rMsg), {
time: this.currentTime, time: this.currentTime,
tabId: this.currentTab,
}, !this.noIndexes ? { _index: index } : {}) }, !this.noIndexes ? { _index: index } : {})
return msg return msg

View file

@ -11,18 +11,28 @@ export default class MStreamReader {
private t: number = 0 private t: number = 0
private idx: number = 0 private idx: number = 0
currentTab = 'back-compatability'
readNext(): Message & { _index: number } | null { readNext(): Message & { _index: number } | null {
let msg = this.r.readMessage() let msg = this.r.readMessage()
if (msg === null) { return null } if (msg === null) { return null }
if (msg.tp === MType.Timestamp) { if (msg.tp === MType.Timestamp) {
this.startTs = this.startTs || msg.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 this.readNext()
} }
return Object.assign(msg, { return Object.assign(msg, {
time: this.t, time: this.t,
_index: this.idx++, _index: this.idx++,
tabId: this.currentTab,
}) })
} }
} }

View file

@ -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: { case 90: {
const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() } const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() }
const projectID = this.readUint(); if (projectID === null) { return resetPointer() } const projectID = this.readUint(); if (projectID === null) { return resetPointer() }

View file

@ -3,7 +3,7 @@
import { MType } from './raw.gen' 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) { export function isDOMType(t: MType) {
return DOM_TYPES.includes(t) return DOM_TYPES.includes(t)
} }

View file

@ -58,6 +58,8 @@ import type {
RawSelectionChange, RawSelectionChange,
RawMouseThrashing, RawMouseThrashing,
RawResourceTiming, RawResourceTiming,
RawTabChange,
RawTabData,
RawIosSessionStart, RawIosSessionStart,
RawIosCustomEvent, RawIosCustomEvent,
RawIosScreenChanges, RawIosScreenChanges,
@ -178,6 +180,10 @@ export type MouseThrashing = RawMouseThrashing & Timed
export type ResourceTiming = RawResourceTiming & Timed export type ResourceTiming = RawResourceTiming & Timed
export type TabChange = RawTabChange & Timed
export type TabData = RawTabData & Timed
export type IosSessionStart = RawIosSessionStart & Timed export type IosSessionStart = RawIosSessionStart & Timed
export type IosCustomEvent = RawIosCustomEvent & Timed export type IosCustomEvent = RawIosCustomEvent & Timed

View file

@ -56,6 +56,8 @@ export const enum MType {
SelectionChange = 113, SelectionChange = 113,
MouseThrashing = 114, MouseThrashing = 114,
ResourceTiming = 116, ResourceTiming = 116,
TabChange = 117,
TabData = 118,
IosSessionStart = 90, IosSessionStart = 90,
IosCustomEvent = 93, IosCustomEvent = 93,
IosScreenChanges = 96, IosScreenChanges = 96,
@ -447,6 +449,16 @@ export interface RawResourceTiming {
cached: boolean, cached: boolean,
} }
export interface RawTabChange {
tp: MType.TabChange,
tabId: string,
}
export interface RawTabData {
tp: MType.TabData,
tabId: string,
}
export interface RawIosSessionStart { export interface RawIosSessionStart {
tp: MType.IosSessionStart, tp: MType.IosSessionStart,
timestamp: number, 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;

View file

@ -57,6 +57,8 @@ export const TP_MAP = {
113: MType.SelectionChange, 113: MType.SelectionChange,
114: MType.MouseThrashing, 114: MType.MouseThrashing,
116: MType.ResourceTiming, 116: MType.ResourceTiming,
117: MType.TabChange,
118: MType.TabData,
90: MType.IosSessionStart, 90: MType.IosSessionStart,
93: MType.IosCustomEvent, 93: MType.IosCustomEvent,
96: MType.IosScreenChanges, 96: MType.IosScreenChanges,

View file

@ -470,8 +470,18 @@ type TrResourceTiming = [
cached: boolean, 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 { export default function translate(tMsg: TrackerMessage): RawMessage | null {
switch(tMsg[0]) { 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: default:
return null return null
} }

View file

@ -12,16 +12,15 @@ export enum StorageType {
export const STORAGE_TYPES = StorageType // TODO: update name everywhere export const STORAGE_TYPES = StorageType // TODO: update name everywhere
export function selectStorageType(state: State): StorageType { 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 return StorageType.REDUX
} else if (state.vuexList.length > 0) { } else if (state.vuexList?.length > 0) {
return StorageType.VUEX return StorageType.VUEX
} else if (state.mobxList.length > 0) { } else if (state.mobxList?.length > 0) {
return StorageType.MOBX return StorageType.MOBX
} else if (state.ngrxList.length > 0) { } else if (state.ngrxList?.length > 0) {
return StorageType.NGRX return StorageType.NGRX
} else if (state.zustandList.length > 0) { } else if (state.zustandList?.length > 0) {
return StorageType.ZUSTAND return StorageType.ZUSTAND
} }
return StorageType.NONE return StorageType.NONE

View file

@ -13,6 +13,7 @@ export interface ILog {
time: number time: number
index?: number index?: number
errorId?: string errorId?: string
tabId?: string
} }
export const Log = (log: ILog) => ({ export const Log = (log: ILog) => ({

View file

@ -23,6 +23,7 @@ interface IEvent {
key: number; key: number;
label: string; label: string;
targetPath: string; targetPath: string;
tabId?: string;
target: { target: {
path: string; path: string;
label: string; label: string;
@ -69,12 +70,14 @@ class Event {
time: IEvent['time']; time: IEvent['time'];
label: IEvent['label']; label: IEvent['label'];
target: IEvent['target']; target: IEvent['target'];
tabId: IEvent['tabId'];
constructor(event: IEvent) { constructor(event: IEvent) {
Object.assign(this, { Object.assign(this, {
time: event.time, time: event.time,
label: event.label, label: event.label,
key: event.key, key: event.key,
tabId: event.tabId,
target: { target: {
path: event.target?.path || event.targetPath, path: event.target?.path || event.targetPath,
label: event.target?.label, label: event.target?.label,

View file

@ -1 +1 @@
export { default } from './session'; export { default, mergeEventLists } from './session';

View file

@ -9,7 +9,7 @@ import { toJS } from 'mobx';
const HASH_MOD = 1610612741; const HASH_MOD = 1610612741;
const HASH_P = 53; const HASH_P = 53;
function mergeEventLists<T extends Record<string, any>, Y extends Record<string, any>>(arr1: T[], arr2: Y[]): Array<T | Y> { export function mergeEventLists<T extends Record<string, any>, Y extends Record<string, any>>(arr1: T[], arr2: Y[]): Array<T | Y> {
let merged = []; let merged = [];
let index1 = 0; let index1 = 0;
let index2 = 0; let index2 = 0;

View file

@ -34,7 +34,7 @@ describe(
cy.get('#redcounter').click().click().click(); cy.get('#redcounter').click().click().click();
cy.get('#test-api').click().click(); cy.get('#test-api').click().click();
cy.get('#test-event').click().click(); cy.get('#test-event').click().click();
cy.wait(SECOND * 3); cy.wait(SECOND * 15);
cy.log('finished generating a session') cy.log('finished generating a session')

View file

@ -484,6 +484,14 @@ message 116, 'ResourceTiming', :replayer => :devtools do
boolean 'Cached' boolean 'Cached'
end end
message 117, 'TabChange' do
string 'TabId'
end
message 118, 'TabData' do
string 'TabId'
end
## Backend-only ## Backend-only
message 125, 'IssueEvent', :replayer => false, :tracker => false do message 125, 'IssueEvent', :replayer => false, :tracker => false do
uint 'MessageID' uint 'MessageID'

View file

@ -6,3 +6,4 @@ cjs
.cache .cache
*.cache *.cache
*.DS_Store *.DS_Store
coverage

View file

@ -1,3 +1,7 @@
## 6.0.0
- added support for multi tab assist session
## 5.0.2 ## 5.0.2
- Added `onCallDeny`, `onRemoteControlDeny` and `onRecordingDeny` callbacks to signal denial of user's consent to call/control/recording - Added `onCallDeny`, `onRemoteControlDeny` and `onRecordingDeny` callbacks to signal denial of user's consent to call/control/recording

View file

@ -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

View file

@ -1,7 +1,7 @@
{ {
"name": "@openreplay/tracker-assist", "name": "@openreplay/tracker-assist",
"description": "Tracker plugin for screen assistance through the WebRTC", "description": "Tracker plugin for screen assistance through the WebRTC",
"version": "5.0.2", "version": "6.0.0-beta.1",
"keywords": [ "keywords": [
"WebRTC", "WebRTC",
"assistance", "assistance",
@ -13,6 +13,7 @@
"type": "module", "type": "module",
"main": "./lib/index.js", "main": "./lib/index.js",
"scripts": { "scripts": {
"tsrun": "tsc",
"lint": "eslint src --ext .ts,.js --fix --quiet", "lint": "eslint src --ext .ts,.js --fix --quiet",
"build": "npm run build-es && npm run build-cjs", "build": "npm run build-es && npm run build-cjs",
"build-es": "rm -Rf lib && tsc && npm run replace-versions", "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'", "replace-req-version": "replace-in-files lib/* cjs/* --string='REQUIRED_TRACKER_VERSION' --replacement='3.5.14'",
"prepublishOnly": "npm run build", "prepublishOnly": "npm run build",
"prepare": "cd ../../ && husky install tracker/.husky/", "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": { "dependencies": {
"csstype": "^3.0.10", "csstype": "^3.0.10",
@ -31,7 +35,7 @@
"socket.io-client": "^4.4.1" "socket.io-client": "^4.4.1"
}, },
"peerDependencies": { "peerDependencies": {
"@openreplay/tracker": ">=5.0.0" "@openreplay/tracker": ">=8.0.0"
}, },
"devDependencies": { "devDependencies": {
"@openreplay/tracker": "file:../tracker", "@openreplay/tracker": "file:../tracker",
@ -41,9 +45,12 @@
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"husky": "^8.0.1", "husky": "^8.0.1",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
"lint-staged": "^13.0.3", "lint-staged": "^13.0.3",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"replace-in-files-cli": "^1.0.0", "replace-in-files-cli": "^1.0.0",
"ts-jest": "^29.0.3",
"typescript": "^4.6.0-dev.20211126" "typescript": "^4.6.0-dev.20211126"
}, },
"husky": { "husky": {

View file

@ -127,8 +127,8 @@ export default class Assist {
app.session.attachUpdateCallback(sessInfo => this.emit('UPDATE_SESSION', sessInfo)) app.session.attachUpdateCallback(sessInfo => this.emit('UPDATE_SESSION', sessInfo))
} }
private emit(ev: string, ...args): void { private emit(ev: string, args?: any): void {
this.socket && this.socket.emit(ev, ...args) this.socket && this.socket.emit(ev, { meta: { tabId: this.app.getTabId(), }, data: args, })
} }
private get agentsConnected(): boolean { private get agentsConnected(): boolean {
@ -164,7 +164,7 @@ export default class Assist {
if (!sessionId) { if (!sessionId) {
return app.debug.error('No session ID') return app.debug.error('No session ID')
} }
const peerID = `${app.getProjectKey()}-${sessionId}` const peerID = `${app.getProjectKey()}-${sessionId}-${this.app.getTabId()}`
// SocketIO // SocketIO
const socket = this.socket = connect(this.getHost(), { const socket = this.socket = connect(this.getHost(), {
@ -172,6 +172,7 @@ export default class Assist {
query: { query: {
'peerId': peerID, 'peerId': peerID,
'identity': 'session', 'identity': 'session',
'tabId': this.app.getTabId(),
'sessionInfo': JSON.stringify({ 'sessionInfo': JSON.stringify({
pageTitle: document.title, pageTitle: document.title,
active: true, active: true,
@ -180,7 +181,12 @@ export default class Assist {
}, },
transports: ['websocket',], 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.remoteControl = new RemoteControl(
this.options, this.options,
@ -197,7 +203,11 @@ export default class Assist {
annot.mount() annot.mount()
return callingAgents.get(id) return callingAgents.get(id)
}, },
(id, isDenied) => { (id, isDenied) => onRelease(id, isDenied),
)
const onRelease = (id, isDenied) => {
{
if (id) { if (id) {
const cb = this.agents[id].onControlReleased const cb = this.agents[id].onControlReleased
delete this.agents[id].onControlReleased delete this.agents[id].onControlReleased
@ -217,8 +227,8 @@ export default class Assist {
const info = id ? this.agents[id]?.agentInfo : {} const info = id ? this.agents[id]?.agentInfo : {}
this.options.onRemoteControlDeny?.(info || {}) this.options.onRemoteControlDeny?.(info || {})
} }
}, }
) }
const onAcceptRecording = () => { const onAcceptRecording = () => {
socket.emit('recording_accepted') socket.emit('recording_accepted')
@ -230,24 +240,37 @@ export default class Assist {
} }
const recordingState = new ScreenRecordingState(this.options.recordingConfirm) const recordingState = new ScreenRecordingState(this.options.recordingConfirm)
// TODO: check incoming args function processEvent(agentId: string, event: { meta: { tabId: string }, data?: any }, callback?: (id: string, data: any) => void) {
socket.on('request_control', this.remoteControl.requestControl) if (app.getTabId() === event.meta.tabId) {
socket.on('release_control', this.remoteControl.releaseControl) return callback?.(agentId, event.data)
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)
} }
}) }
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 // TODO: restrict by id
socket.on('startAnnotation', (_, p) => annot && annot.start(p)) socket.on('moveAnnotation', (id, event) => processEvent(id, event, (_, d) => annot && annot.move(d)))
socket.on('stopAnnotation', () => annot && annot.stop()) 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) => { socket.on('NEW_AGENT', (id: string, info) => {
this.agents[id] = { this.agents[id] = {
@ -256,7 +279,10 @@ export default class Assist {
} }
this.assistDemandedRestart = true this.assistDemandedRestart = true
this.app.stop() 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[]) => { socket.on('AGENTS_CONNECTED', (ids: string[]) => {
ids.forEach(id =>{ ids.forEach(id =>{
@ -268,7 +294,10 @@ export default class Assist {
}) })
this.assistDemandedRestart = true this.assistDemandedRestart = true
this.app.stop() 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) this.remoteControl?.reconnect(ids)
}) })
@ -287,7 +316,8 @@ export default class Assist {
this.agents = {} this.agents = {}
if (recordingState.isActive) recordingState.stopRecording() if (recordingState.isActive) recordingState.stopRecording()
}) })
socket.on('call_end', (id) => { socket.on('call_end', (info) => {
const id = info.data
if (!callingAgents.has(id)) { if (!callingAgents.has(id)) {
app.debug.warn('Received call_end from unknown agent', id) app.debug.warn('Received call_end from unknown agent', id)
return return
@ -295,14 +325,20 @@ export default class Assist {
endAgentCall(id) 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) callingAgents.set(id, name)
updateCallerNames() updateCallerNames()
}) })
socket.on('videofeed', (_, feedState) => { socket.on('videofeed', (_, info) => {
if (app.getTabId() !== info.meta.tabId) return
const feedState = info.data
callUI?.toggleVideoStream(feedState) 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) { if (!recordingState.isActive) {
this.options.onRecordingRequest?.(JSON.parse(agentData)) this.options.onRecordingRequest?.(JSON.parse(agentData))
recordingState.requestRecording(id, onAcceptRecording, () => onRejectRecording(agentData)) recordingState.requestRecording(id, onAcceptRecording, () => onRejectRecording(agentData))
@ -310,7 +346,8 @@ export default class Assist {
this.emit('recording_busy') 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) { if (recordingState.isActive) {
recordingState.stopAgentRecording(id) recordingState.stopAgentRecording(id)
} }
@ -482,6 +519,11 @@ export default class Assist {
}) })
call.answer(lStreams[call.peer].stream) call.answer(lStreams[call.peer].stream)
document.addEventListener('visibilitychange', () => {
initiateCallEnd()
})
this.setCallingState(CallingState.True) this.setCallingState(CallingState.True)
if (!callEndCallback) { callEndCallback = this.options.onCallStart?.() } if (!callEndCallback) { callEndCallback = this.options.onCallStart?.() }

View file

@ -18,8 +18,8 @@ if (nativeInputValueDescriptor && nativeInputValueDescriptor.set) {
export default class RemoteControl { export default class RemoteControl {
private mouse: Mouse | null private mouse: Mouse | null = null
status: RCStatus = RCStatus.Disabled public status: RCStatus = RCStatus.Disabled
private agentID: string | null = null private agentID: string | null = null
constructor( constructor(
@ -89,6 +89,9 @@ export default class RemoteControl {
} }
this.mouse = new Mouse(agentName) this.mouse = new Mouse(agentName)
this.mouse.mount() this.mouse.mount()
document.addEventListener('visibilitychange', () => {
if (document.hidden) this.releaseControl(false)
})
} }
resetMouse = () => { resetMouse = () => {
@ -97,7 +100,9 @@ export default class RemoteControl {
} }
scroll = (id, d) => { id === this.agentID && this.mouse?.scroll(d) } 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 private focused: HTMLElement | null = null
click = (id, xy) => { click = (id, xy) => {
if (id !== this.agentID || !this.mouse) { return } if (id !== this.agentID || !this.mouse) { return }
@ -109,7 +114,9 @@ export default class RemoteControl {
input = (id, value: string) => { input = (id, value: string) => {
if (id !== this.agentID || !this.mouse || !this.focused) { return } if (id !== this.agentID || !this.mouse || !this.focused) { return }
if (this.focused instanceof HTMLTextAreaElement 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) setInputValue.call(this.focused, value)
const ev = new Event('input', { bubbles: true,}) const ev = new Event('input', { bubbles: true,})
this.focused.dispatchEvent(ev) this.focused.dispatchEvent(ev)

View file

@ -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)
})
})

View file

@ -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)
})
})

View file

@ -2,6 +2,8 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"module": "CommonJS", "module": "CommonJS",
"outDir": "./cjs" "outDir": "./cjs",
"rootDir": "src"
}, },
"exclude": ["**/*.test.ts"]
} }

View file

@ -8,6 +8,8 @@
"moduleResolution": "node", "moduleResolution": "node",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"declaration": true, "declaration": true,
"outDir": "./lib" "outDir": "./lib",
} "rootDir": "src"
},
"exclude": ["**/*.test.ts"]
} }

View file

@ -1,3 +1,7 @@
# 8.0.0
- **[breaking]** support for multi-tab sessions
# 7.0.3 # 7.0.3
- Prevent auto restart after manual stop - Prevent auto restart after manual stop

View file

@ -1,7 +1,7 @@
{ {
"name": "@openreplay/tracker", "name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package", "description": "The OpenReplay tracker main package",
"version": "7.0.3", "version": "8.0.0-beta.1",
"keywords": [ "keywords": [
"logging", "logging",
"replay" "replay"
@ -22,7 +22,10 @@
"build": "npm run clean && npm run tscRun && npm run rollup && npm run compile", "build": "npm run clean && npm run tscRun && npm run rollup && npm run compile",
"prepare": "cd ../../ && husky install tracker/.husky/", "prepare": "cd ../../ && husky install tracker/.husky/",
"lint-front": "lint-staged", "lint-front": "lint-staged",
"test": "jest" "test": "jest --coverage=false",
"test:ci": "jest --coverage=true",
"postversion": "npm run build",
"prepublishOnly": "npm run build"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.10.2", "@babel/core": "^7.10.2",

View file

@ -11,6 +11,7 @@ type Start = {
pageNo: number pageNo: number
timestamp: number timestamp: number
url: string url: string
tabId: string
} & Options } & Options
type Auth = { type Auth = {

View file

@ -68,6 +68,8 @@ export declare const enum Type {
MouseThrashing = 114, MouseThrashing = 114,
UnbindNodes = 115, UnbindNodes = 115,
ResourceTiming = 116, ResourceTiming = 116,
TabChange = 117,
TabData = 118,
} }
@ -536,6 +538,16 @@ export type ResourceTiming = [
/*cached:*/ boolean, /*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 export default Message

View file

@ -1,5 +1,5 @@
import type Message from './messages.gen.js' 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 { now, adjustTimeOrigin, deprecationWarn } from '../utils.js'
import Nodes from './nodes.js' import Nodes from './nodes.js'
import Observer from './observer/top_observer.js' import Observer from './observer/top_observer.js'
@ -46,6 +46,12 @@ type UnsuccessfulStart = {
reason: typeof CANCELED | string reason: typeof CANCELED | string
success: false 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 UnsuccessfulStart = (reason: string): UnsuccessfulStart => ({ reason, success: false })
const SuccessfulStart = (body: OnStartInfo): SuccessfulStart => ({ ...body, success: true }) const SuccessfulStart = (body: OnStartInfo): SuccessfulStart => ({ ...body, success: true })
export type StartPromiseReturn = SuccessfulStart | UnsuccessfulStart export type StartPromiseReturn = SuccessfulStart | UnsuccessfulStart
@ -64,6 +70,7 @@ type AppOptions = {
session_reset_key: string session_reset_key: string
session_token_key: string session_token_key: string
session_pageno_key: string session_pageno_key: string
session_tabid_key: string
local_uuid_key: string local_uuid_key: string
ingestPoint: string ingestPoint: string
resourceBaseHref: string | null // resourceHref? resourceBaseHref: string | null // resourceHref?
@ -74,6 +81,7 @@ type AppOptions = {
__debug__?: LoggerOptions __debug__?: LoggerOptions
localStorage: Storage | null localStorage: Storage | null
sessionStorage: Storage | null sessionStorage: Storage | null
forceSingleTab?: boolean
// @deprecated // @deprecated
onStart?: StartCallback onStart?: StartCallback
@ -109,6 +117,7 @@ export default class App {
private readonly worker?: TypedWorker private readonly worker?: TypedWorker
private compressionThreshold = 24 * 1000 private compressionThreshold = 24 * 1000
private restartAttempts = 0 private restartAttempts = 0
private readonly bc: BroadcastChannel = new BroadcastChannel('rick')
constructor(projectKey: string, sessionToken: string | undefined, options: Partial<Options>) { constructor(projectKey: string, sessionToken: string | undefined, options: Partial<Options>) {
// if (options.onStart !== undefined) { // if (options.onStart !== undefined) {
@ -124,6 +133,7 @@ export default class App {
session_token_key: '__openreplay_token', session_token_key: '__openreplay_token',
session_pageno_key: '__openreplay_pageno', session_pageno_key: '__openreplay_pageno',
session_reset_key: '__openreplay_reset', session_reset_key: '__openreplay_reset',
session_tabid_key: '__openreplay_tabid',
local_uuid_key: '__openreplay_uuid', local_uuid_key: '__openreplay_uuid',
ingestPoint: DEFAULT_INGEST_POINT, ingestPoint: DEFAULT_INGEST_POINT,
resourceBaseHref: null, resourceBaseHref: null,
@ -132,6 +142,7 @@ export default class App {
__debug_report_edp: null, __debug_report_edp: null,
localStorage: null, localStorage: null,
sessionStorage: null, sessionStorage: null,
forceSingleTab: false,
}, },
options, options,
) )
@ -212,6 +223,30 @@ export default class App {
} catch (e) { } catch (e) {
this._debug('worker_start', 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<RickRoll>) => {
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) { private _debug(context: string, e: any) {
@ -257,6 +292,7 @@ export default class App {
} }
private commit(): void { private commit(): void {
if (this.worker && this.messages.length) { if (this.worker && this.messages.length) {
this.messages.unshift(TabData(this.session.getTabId()))
this.messages.unshift(Timestamp(this.timestamp())) this.messages.unshift(Timestamp(this.timestamp()))
this.worker.postMessage(this.messages) this.worker.postMessage(this.messages)
this.commitCallbacks.forEach((cb) => cb(this.messages)) this.commitCallbacks.forEach((cb) => cb(this.messages))
@ -455,12 +491,16 @@ export default class App {
url: document.URL, url: document.URL,
connAttemptCount: this.options.connAttemptCount, connAttemptCount: this.options.connAttemptCount,
connAttemptGap: this.options.connAttemptGap, connAttemptGap: this.options.connAttemptGap,
tabId: this.session.getTabId(),
}) })
const lsReset = this.sessionStorage.getItem(this.options.session_reset_key) !== null const lsReset = this.sessionStorage.getItem(this.options.session_reset_key) !== null
this.sessionStorage.removeItem(this.options.session_reset_key) this.sessionStorage.removeItem(this.options.session_reset_key)
const needNewSessionID = startOpts.forceNew || lsReset || resetByWorker const needNewSessionID = startOpts.forceNew || lsReset || resetByWorker
const sessionToken = this.session.getSessionToken()
const isNewSession = needNewSessionID || !sessionToken
console.log('OpenReplay: starting session', needNewSessionID, sessionToken)
return window return window
.fetch(this.options.ingestPoint + '/v1/web/start', { .fetch(this.options.ingestPoint + '/v1/web/start', {
method: 'POST', method: 'POST',
@ -471,7 +511,7 @@ export default class App {
...this.getTrackerInfo(), ...this.getTrackerInfo(),
timestamp, timestamp,
userID: this.session.getInfo().userID, userID: this.session.getInfo().userID,
token: needNewSessionID ? undefined : this.session.getSessionToken(), token: isNewSession ? undefined : sessionToken,
deviceMemory, deviceMemory,
jsHeapSizeLimit, jsHeapSizeLimit,
}), }),
@ -523,6 +563,11 @@ export default class App {
timestamp: startTimestamp || timestamp, timestamp: startTimestamp || timestamp,
projectID, 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 // (Re)send Metadata for the case of a new session
Object.entries(this.session.getInfo().metadata).forEach(([key, value]) => Object.entries(this.session.getInfo().metadata).forEach(([key, value]) =>
this.send(Metadata(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<App['_start']>): Promise<StartPromiseReturn> { start(...args: Parameters<App['_start']>): Promise<StartPromiseReturn> {
if (!document.hidden) { if (!document.hidden) {
return this._start(...args) return new Promise((resolve) => {
setTimeout(() => {
resolve(this._start(...args))
}, 10)
})
} else { } else {
return new Promise((resolve) => { return new Promise((resolve) => {
const onVisibilityChange = () => { const onVisibilityChange = () => {
if (!document.hidden) { if (!document.hidden) {
document.removeEventListener('visibilitychange', onVisibilityChange) document.removeEventListener('visibilitychange', onVisibilityChange)
resolve(this._start(...args)) setTimeout(() => {
resolve(this._start(...args))
}, 10)
} }
} }
document.addEventListener('visibilitychange', onVisibilityChange) document.addEventListener('visibilitychange', onVisibilityChange)
}) })
} }
} }
getTabId() {
return this.session.getTabId()
}
stop(stopWorker = true): void { stop(stopWorker = true): void {
if (this.activityState !== ActivityState.NotActive) { if (this.activityState !== ActivityState.NotActive) {
try { try {

View file

@ -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,
]
}

View file

@ -14,6 +14,11 @@ export interface Options {
domSanitizer?: (node: Element) => SanitizeLevel 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 { export default class Sanitizer {
private readonly obscured: Set<number> = new Set() private readonly obscured: Set<number> = new Set()
private readonly hidden: Set<number> = new Set() private readonly hidden: Set<number> = new Set()
@ -59,10 +64,9 @@ export default class Sanitizer {
sanitize(id: number, data: string): string { sanitize(id: number, data: string): string {
if (this.obscured.has(id)) { if (this.obscured.has(id)) {
// TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases? // TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases?
return data return stringWiper(data)
.trim()
.replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g, '█')
} }
if (this.options.obscureTextNumbers) { if (this.options.obscureTextNumbers) {
data = data.replace(/\d/g, '0') data = data.replace(/\d/g, '0')
} }

View file

@ -1,4 +1,5 @@
import type App from './index.js' import type App from './index.js'
import { generateRandomId } from '../utils.js'
interface SessionInfo { interface SessionInfo {
sessionID: string | undefined sessionID: string | undefined
@ -12,6 +13,7 @@ type OnUpdateCallback = (i: Partial<SessionInfo>) => void
export type Options = { export type Options = {
session_token_key: string session_token_key: string
session_pageno_key: string session_pageno_key: string
session_tabid_key: string
} }
export default class Session { export default class Session {
@ -21,8 +23,11 @@ export default class Session {
private readonly callbacks: OnUpdateCallback[] = [] private readonly callbacks: OnUpdateCallback[] = []
private timestamp = 0 private timestamp = 0
private projectID: string | undefined 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) { attachUpdateCallback(cb: OnUpdateCallback) {
this.callbacks.push(cb) this.callbacks.push(cb)
@ -61,6 +66,7 @@ export default class Session {
this.metadata[key] = value this.metadata[key] = value
this.handleUpdate({ metadata: { [key]: value } }) this.handleUpdate({ metadata: { [key]: value } })
} }
setUserID(userID: string) { setUserID(userID: string) {
this.userID = userID this.userID = userID
this.handleUpdate({ userID }) this.handleUpdate({ userID })
@ -88,6 +94,7 @@ export default class Session {
getSessionToken(): string | undefined { getSessionToken(): string | undefined {
return this.app.sessionStorage.getItem(this.options.session_token_key) || undefined return this.app.sessionStorage.getItem(this.options.session_token_key) || undefined
} }
setSessionToken(token: string): void { setSessionToken(token: string): void {
this.app.sessionStorage.setItem(this.options.session_token_key, token) this.app.sessionStorage.setItem(this.options.session_token_key, token)
} }
@ -115,6 +122,22 @@ export default class Session {
return encodeURI(String(pageNo) + '&' + token) 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 { getInfo(): SessionInfo {
return { return {
sessionID: this.sessionID, sessionID: this.sessionID,

View file

@ -25,6 +25,7 @@ import Fonts from './modules/fonts.js'
import Network from './modules/network.js' import Network from './modules/network.js'
import ConstructedStyleSheets from './modules/constructedStyleSheets.js' import ConstructedStyleSheets from './modules/constructedStyleSheets.js'
import Selection from './modules/selection.js' import Selection from './modules/selection.js'
import Tabs from './modules/tabs.js'
import { IN_BROWSER, deprecationWarn, DOCS_HOST } from './utils.js' import { IN_BROWSER, deprecationWarn, DOCS_HOST } from './utils.js'
import type { Options as AppOptions } from './app/index.js' import type { Options as AppOptions } from './app/index.js'
@ -136,6 +137,7 @@ export default class API {
Fonts(app) Fonts(app)
Network(app, options.network) Network(app, options.network)
Selection(app) Selection(app)
Tabs(app)
;(window as any).__OPENREPLAY__ = this ;(window as any).__OPENREPLAY__ = this
if (options.autoResetOnWindowOpen) { if (options.autoResetOnWindowOpen) {
@ -216,6 +218,13 @@ export default class API {
} }
return this.app.getSessionID() return this.app.getSessionID()
} }
getTabId() {
if (this.app === null) {
return null
}
return this.app.getTabId()
}
sessionID(): string | null | undefined { sessionID(): string | null | undefined {
deprecationWarn("'sessionID' method", "'getSessionID' method", '/') deprecationWarn("'sessionID' method", "'getSessionID' method", '/')
return this.getSessionID() return this.getSessionID()

View file

@ -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)
}

View file

@ -82,14 +82,6 @@ export function hasOpenreplayAttribute(e: Element, attr: string): boolean {
return false 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 * checks if iframe is accessible
**/ **/
@ -100,3 +92,16 @@ export function canAccessIframe(iframe: HTMLIFrameElement) {
return false 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('')
}

View file

@ -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)
})
})

View file

@ -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('')
})
})

View file

@ -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)
})
})

View file

@ -19,6 +19,7 @@ export default class BatchWriter {
private timestamp: number, private timestamp: number,
private url: string, private url: string,
private readonly onBatch: (batch: Uint8Array) => void, private readonly onBatch: (batch: Uint8Array) => void,
private tabId: string,
) { ) {
this.prepare() this.prepare()
} }
@ -51,8 +52,12 @@ export default class BatchWriter {
this.timestamp, this.timestamp,
this.url, this.url,
] ]
const tabData: Messages.TabData = [Messages.Type.TabData, this.tabId]
this.writeType(batchMetadata) this.writeType(batchMetadata)
this.writeFields(batchMetadata) this.writeFields(batchMetadata)
this.writeWithSize(tabData as Message)
this.isEmpty = true this.isEmpty = true
} }

View file

@ -9,7 +9,7 @@ describe('BatchWriter', () => {
beforeEach(() => { beforeEach(() => {
onBatchMock = jest.fn() onBatchMock = jest.fn()
batchWriter = new BatchWriter(1, 123456789, 'example.com', onBatchMock) batchWriter = new BatchWriter(1, 123456789, 'example.com', onBatchMock, '123')
}) })
afterEach(() => { afterEach(() => {
@ -21,7 +21,8 @@ describe('BatchWriter', () => {
expect(batchWriter['timestamp']).toBe(123456789) expect(batchWriter['timestamp']).toBe(123456789)
expect(batchWriter['url']).toBe('example.com') expect(batchWriter['url']).toBe('example.com')
expect(batchWriter['onBatch']).toBe(onBatchMock) 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['beaconSize']).toBe(200000)
expect(batchWriter['encoder']).toBeDefined() expect(batchWriter['encoder']).toBeDefined()
expect(batchWriter['strDict']).toBeDefined() expect(batchWriter['strDict']).toBeDefined()
@ -30,12 +31,14 @@ describe('BatchWriter', () => {
}) })
test('writeType writes the type of the message', () => { test('writeType writes the type of the message', () => {
// @ts-ignore
const message = [Messages.Type.BatchMetadata, 1, 2, 3, 4, 'example.com'] const message = [Messages.Type.BatchMetadata, 1, 2, 3, 4, 'example.com']
const result = batchWriter['writeType'](message as Message) const result = batchWriter['writeType'](message as Message)
expect(result).toBe(true) expect(result).toBe(true)
}) })
test('writeFields encodes the message fields', () => { test('writeFields encodes the message fields', () => {
// @ts-ignore
const message = [Messages.Type.BatchMetadata, 1, 2, 3, 4, 'example.com'] const message = [Messages.Type.BatchMetadata, 1, 2, 3, 4, 'example.com']
const result = batchWriter['writeFields'](message as Message) const result = batchWriter['writeFields'](message as Message)
expect(result).toBe(true) expect(result).toBe(true)
@ -52,6 +55,7 @@ describe('BatchWriter', () => {
}) })
test('writeWithSize writes the message with its size', () => { test('writeWithSize writes the message with its size', () => {
// @ts-ignore
const message = [Messages.Type.BatchMetadata, 1, 2, 3, 4, 'example.com'] const message = [Messages.Type.BatchMetadata, 1, 2, 3, 4, 'example.com']
const result = batchWriter['writeWithSize'](message as Message) const result = batchWriter['writeWithSize'](message as Message)
expect(result).toBe(true) expect(result).toBe(true)
@ -72,6 +76,7 @@ describe('BatchWriter', () => {
}) })
test('writeMessage writes the given message', () => { test('writeMessage writes the given message', () => {
// @ts-ignore
const message = [Messages.Type.Timestamp, 987654321] const message = [Messages.Type.Timestamp, 987654321]
// @ts-ignore // @ts-ignore
batchWriter['writeWithSize'] = jest.fn().mockReturnValue(true) batchWriter['writeWithSize'] = jest.fn().mockReturnValue(true)

View file

@ -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]) 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 break
case Messages.Type.TabChange:
return this.string(msg[1])
break
case Messages.Type.TabData:
return this.string(msg[1])
break
} }
} }

View file

@ -135,6 +135,6 @@ export default class QueueSender {
setTimeout(() => { setTimeout(() => {
this.token = null this.token = null
this.queue.length = 0 this.queue.length = 0
}, 100) }, 10)
} }
} }

View file

@ -4,13 +4,11 @@ import QueueSender from './QueueSender.js'
global.fetch = () => Promise.resolve(new Response()) // jsdom does not have it global.fetch = () => Promise.resolve(new Response()) // jsdom does not have it
function mockFetch(status: number, headers?: Record<string, string>) { function mockFetch(status: number, headers?: Record<string, string>) {
return jest return jest.spyOn(global, 'fetch').mockImplementation((request) =>
.spyOn(global, 'fetch') Promise.resolve({ status, headers, request } as unknown as Response & {
.mockImplementation((request) => request: RequestInfo
Promise.resolve({ status, headers, request } as unknown as Response & { }),
request: RequestInfo )
}),
)
} }
const baseURL = 'MYBASEURL' const baseURL = 'MYBASEURL'
const sampleArray = new Uint8Array(1) const sampleArray = new Uint8Array(1)
@ -40,6 +38,7 @@ function defaultQueueSender({
describe('QueueSender', () => { describe('QueueSender', () => {
afterEach(() => { afterEach(() => {
jest.restoreAllMocks() jest.restoreAllMocks()
jest.useRealTimers()
}) })
// Test fetch first parameter + authorization header to be present // Test fetch first parameter + authorization header to be present
@ -93,9 +92,10 @@ describe('QueueSender', () => {
test("Doesn't call fetch on push() after clean()", () => { test("Doesn't call fetch on push() after clean()", () => {
const queueSender = defaultQueueSender() const queueSender = defaultQueueSender()
const fetchMock = mockFetch(200) const fetchMock = mockFetch(200)
jest.useFakeTimers()
queueSender.authorise(randomToken) queueSender.authorise(randomToken)
queueSender.clean() queueSender.clean()
jest.runAllTimers()
queueSender.push(sampleArray) queueSender.push(sampleArray)
expect(fetchMock).not.toBeCalled() expect(fetchMock).not.toBeCalled()
}) })

View file

@ -21,8 +21,9 @@ const AUTO_SEND_INTERVAL = 10 * 1000
let sender: QueueSender | null = null let sender: QueueSender | null = null
let writer: BatchWriter | null = null let writer: BatchWriter | null = null
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let workerStatus: WorkerStatus = WorkerStatus.NotActive let workerStatus: WorkerStatus = WorkerStatus.NotActive
// let afterSleepRestarts = 0
function finalize(): void { function finalize(): void {
if (!writer) { if (!writer) {
return return
@ -44,7 +45,7 @@ function resetSender(): void {
// allowing some time to send last batch // allowing some time to send last batch
setTimeout(() => { setTimeout(() => {
sender = null sender = null
}, 500) }, 20)
} }
} }
@ -56,7 +57,9 @@ function reset(): void {
} }
resetWriter() resetWriter()
resetSender() resetSender()
workerStatus = WorkerStatus.NotActive setTimeout(() => {
workerStatus = WorkerStatus.NotActive
}, 100)
} }
function initiateRestart(): void { function initiateRestart(): void {
@ -73,7 +76,7 @@ let sendIntervalID: ReturnType<typeof setInterval> | null = null
let restartTimeoutID: ReturnType<typeof setTimeout> let restartTimeoutID: ReturnType<typeof setTimeout>
// @ts-ignore // @ts-ignore
self.onmessage = ({ data }: any): any => { self.onmessage = ({ data }: { data: ToWorkerData }): any => {
if (data == null) { if (data == null) {
finalize() finalize()
return return
@ -146,6 +149,7 @@ self.onmessage = ({ data }: any): any => {
data.timestamp, data.timestamp,
data.url, data.url,
(batch) => sender && sender.push(batch), (batch) => sender && sender.push(batch),
data.tabId,
) )
if (sendIntervalID === null) { if (sendIntervalID === null) {
sendIntervalID = setInterval(finalize, AUTO_SEND_INTERVAL) sendIntervalID = setInterval(finalize, AUTO_SEND_INTERVAL)