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
npm i -g yarn
yarn
- name: Jest tests
run: |
cd tracker/tracker
yarn test
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: tracker
name: tracker
- name: Build tracker inst
run: |
cd tracker/tracker

View file

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

View file

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

View file

@ -151,7 +151,7 @@ func main() {
}
// Add message to dev buffer
if !messages.IsDOMType(msg.TypeID()) || msg.TypeID() == messages.MsgTimestamp {
if !messages.IsDOMType(msg.TypeID()) || msg.TypeID() == messages.MsgTimestamp || msg.TypeID() == messages.MsgTabData {
// Write message index
n, err = devBuffer.Write(messageIndex)
if err != nil {

View file

@ -10,5 +10,5 @@ func IsIOSType(id int) bool {
}
func IsDOMType(id int) bool {
return 0 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 37 == id || 38 == id || 49 == id || 50 == id || 51 == id || 54 == id || 55 == id || 57 == id || 58 == id || 59 == id || 60 == id || 61 == id || 67 == id || 69 == id || 70 == id || 71 == id || 72 == id || 73 == id || 74 == id || 75 == id || 76 == id || 77 == id || 113 == id || 114 == id || 90 == id || 93 == id || 96 == id || 100 == id || 102 == id || 103 == id || 105 == id
return 0 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 37 == id || 38 == id || 49 == id || 50 == id || 51 == id || 54 == id || 55 == id || 57 == id || 58 == id || 59 == id || 60 == id || 61 == id || 67 == id || 69 == id || 70 == id || 71 == id || 72 == id || 73 == id || 74 == id || 75 == id || 76 == id || 77 == id || 113 == id || 114 == id || 117 == id || 118 == id || 90 == id || 93 == id || 96 == id || 100 == id || 102 == id || 103 == id || 105 == id
}

View file

@ -81,6 +81,8 @@ const (
MsgMouseThrashing = 114
MsgUnbindNodes = 115
MsgResourceTiming = 116
MsgTabChange = 117
MsgTabData = 118
MsgIssueEvent = 125
MsgSessionEnd = 126
MsgSessionSearch = 127
@ -2163,6 +2165,48 @@ func (msg *ResourceTiming) TypeID() int {
return 116
}
type TabChange struct {
message
TabId string
}
func (msg *TabChange) Encode() []byte {
buf := make([]byte, 11+len(msg.TabId))
buf[0] = 117
p := 1
p = WriteString(msg.TabId, buf, p)
return buf[:p]
}
func (msg *TabChange) Decode() Message {
return msg
}
func (msg *TabChange) TypeID() int {
return 117
}
type TabData struct {
message
TabId string
}
func (msg *TabData) Encode() []byte {
buf := make([]byte, 11+len(msg.TabId))
buf[0] = 118
p := 1
p = WriteString(msg.TabId, buf, p)
return buf[:p]
}
func (msg *TabData) Decode() Message {
return msg
}
func (msg *TabData) TypeID() int {
return 118
}
type IssueEvent struct {
message
MessageID uint64

View file

@ -1314,6 +1314,24 @@ func DecodeResourceTiming(reader BytesReader) (Message, error) {
return msg, err
}
func DecodeTabChange(reader BytesReader) (Message, error) {
var err error = nil
msg := &TabChange{}
if msg.TabId, err = reader.ReadString(); err != nil {
return nil, err
}
return msg, err
}
func DecodeTabData(reader BytesReader) (Message, error) {
var err error = nil
msg := &TabData{}
if msg.TabId, err = reader.ReadString(); err != nil {
return nil, err
}
return msg, err
}
func DecodeIssueEvent(reader BytesReader) (Message, error) {
var err error = nil
msg := &IssueEvent{}
@ -1927,6 +1945,10 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) {
return DecodeUnbindNodes(reader)
case 116:
return DecodeResourceTiming(reader)
case 117:
return DecodeTabChange(reader)
case 118:
return DecodeTabData(reader)
case 125:
return DecodeIssueEvent(reader)
case 126:

View file

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

View file

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

View file

@ -71,7 +71,7 @@ class CreateDocument(Message):
__id__ = 7
def __init__(self, ):
pass
class CreateElementNode(Message):
@ -759,6 +759,20 @@ class ResourceTiming(Message):
self.cached = cached
class TabChange(Message):
__id__ = 117
def __init__(self, tab_id):
self.tab_id = tab_id
class TabData(Message):
__id__ = 118
def __init__(self, tab_id):
self.tab_id = tab_id
class IssueEvent(Message):
__id__ = 125

View file

@ -689,6 +689,16 @@ class MessageCodec(Codec):
cached=self.read_boolean(reader)
)
if message_id == 117:
return TabChange(
tab_id=self.read_string(reader)
)
if message_id == 118:
return TabData(
tab_id=self.read_string(reader)
)
if message_id == 125:
return IssueEvent(
message_id=self.read_uint(reader),

View file

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

View file

@ -1,39 +1,43 @@
import React from 'react';
import { Icon, Tooltip } from 'UI';
import copy from 'copy-to-clipboard';
import { PlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite';
import Tab from 'Components/Session/Player/SharedComponents/Tab';
function SubHeader() {
const { store } = React.useContext(PlayerContext)
const {
location: currentLocation,
} = store.get()
const [isCopied, setCopied] = React.useState(false);
const { store } = React.useContext(PlayerContext);
const { tabStates, currentTab, tabs } = store.get();
const currentLocation = tabStates[currentTab]?.location || '';
const location =
currentLocation !== undefined ? currentLocation.length > 60
? `${currentLocation.slice(0, 60)}...`
: currentLocation : undefined;
currentLocation !== undefined
? currentLocation.length > 70
? `${currentLocation.slice(0, 70)}...`
: currentLocation
: undefined;
return (
<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 && (
<div
className="flex items-center cursor-pointer color-gray-medium text-sm p-1 hover:bg-gray-light-shade rounded-md"
onClick={() => {
copy(currentLocation || '');
setCopied(true);
setTimeout(() => setCopied(false), 5000);
}}
>
<Icon size="20" name="event/link" className="mr-1" />
<Tooltip title={isCopied ? 'URL Copied to clipboard' : 'Click to copy'}>
{location}
</Tooltip>
<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={location} target="_blank">
{location}
</a>
</Tooltip>
</div>
</div>
)}
</div>
</>
);
}

View file

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

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
function buildPng() {
html2canvas(reportRef.current, {
html2canvas(reportRef.current!, {
scale: 2,
ignoreElements: (e) => e.id.includes('pdf-ignore'),
}).then((canvas) => {
@ -147,11 +147,11 @@ function BugReportModal({ hideModal, session, width, height, account, xrayProps,
}
function buildText() {
doc
.html(reportRef.current, {
.html(reportRef.current!, {
x: 0,
y: 0,
width: 210,
windowWidth: reportRef.current.getBoundingClientRect().width,
windowWidth: reportRef.current!.getBoundingClientRect().width,
autoPaging: 'text',
html2canvas: {
ignoreElements: (e) => e.id.includes('pdf-ignore') || e instanceof SVGElement,

View file

@ -1,7 +1,7 @@
import React from 'react';
import cn from 'classnames';
import { connect } from 'react-redux';
import { TextEllipsis } from 'UI';
import { TextEllipsis, Icon } from 'UI';
import withToggle from 'HOCs/withToggle';
import { TYPES } from 'Types/session/event';
import Event from './Event';
@ -57,6 +57,7 @@ class EventGroupWrapper extends React.Component {
isFirst,
presentInSearch,
isNote,
isTabChange,
filterOutNote
} = this.props;
const isLocation = event.type === TYPES.LOCATION;
@ -107,7 +108,7 @@ class EventGroupWrapper extends React.Component {
isLastInGroup={isLastInGroup}
whiteBg={true}
/>
) : (
) : isTabChange ? (<TabChange from={event.fromTab} to={event.toTab} />) : (
<Event
key={event.key}
event={event}
@ -123,10 +124,24 @@ class EventGroupWrapper extends React.Component {
/>
)}
</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;

View file

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

View file

@ -25,7 +25,8 @@ interface IProps {
function Exceptions({ errorStack, sourcemapUploaded, loading }: IProps) {
const { player, store } = React.useContext(PlayerContext);
const { logListNow: logs, exceptionsList: exceptions } = store.get();
const { tabStates, currentTab } = store.get();
const { logListNow: logs = [], exceptionsList: exceptions = [] } = tabStates[currentTab]
const [filter, setFilter] = React.useState('');
const [currentError, setCurrentErrorVal] = React.useState(null);

View file

@ -14,7 +14,8 @@ function renderDefaultStatus() {
function GraphQL() {
const { player, store } = React.useContext(PlayerContext);
const { graphqlList: list, graphqlListNow: listNow, time, livePlay } = store.get();
const { time, livePlay, tabStates, currentTab } = store.get();
const { graphqlList: list = [], graphqlListNow: listNow = [] } = tabStates[currentTab]
const defaultState = {
filter: '',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import { PlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite';
import { JSONTree, NoContent, Tooltip } from 'UI';
import { formatMs } from 'App/date';
// @ts-ignore
import { diff } from 'deep-diff';
import { STORAGE_TYPES, selectStorageList, selectStorageListNow, selectStorageType } from 'Player';
import Autoscroll from '../Autoscroll';
@ -40,12 +41,28 @@ interface Props {
function Storage(props: Props) {
const lastBtnRef = React.useRef<HTMLButtonElement>();
const [showDiffs, setShowDiffs] = React.useState(false);
const { player, store } = React.useContext(PlayerContext);
const state = store.get();
const [stateObject, setState] = React.useState({});
const listNow = selectStorageListNow(state);
const list = selectStorageList(state);
const type = selectStorageType(state);
const { player, store } = React.useContext(PlayerContext);
const { tabStates, currentTab } = store.get()
const state = tabStates[currentTab] || {}
const listNow = selectStorageListNow(state) || [];
const list = selectStorageList(state) || [];
const type = selectStorageType(state) || STORAGE_TYPES.NONE
React.useEffect(() => {
let currentState;
if (listNow.length === 0) {
currentState = decodeMessage(list[0])
} else {
currentState = decodeMessage(listNow[listNow.length - 1])
}
const stateObj = currentState?.state || currentState?.payload?.state || {}
const newState = Object.assign(stateObject, stateObj);
setState(newState);
}, [listNow.length]);
const decodeMessage = (msg: any) => {
const decoded = {};
@ -84,7 +101,11 @@ function Storage(props: Props) {
focusNextButton();
}, [listNow]);
const renderDiff = (item: Record<string, any>, prevItem: Record<string, any>) => {
const renderDiff = (item: Record<string, any>, prevItem?: Record<string, any>) => {
if (!showDiffs) {
return;
}
if (!prevItem) {
// we don't have state before first action
return <div style={{ flex: 3 }} className="p-1" />;
@ -166,7 +187,7 @@ function Storage(props: Props) {
name = itemD.mutation.join('');
}
if (src !== null && !showDiffs) {
if (src !== null && !showDiffs && itemD.state) {
setShowDiffs(true);
}
@ -182,7 +203,7 @@ function Storage(props: Props) {
) : (
<>
{renderDiff(itemD, prevItemD)}
<div style={{ flex: 2 }} className="flex pl-10 pt-2">
<div style={{ flex: 2 }} className={cn("flex pt-2", showDiffs && 'pl-10')}>
<JSONTree
name={ensureString(name)}
src={src}
@ -218,17 +239,14 @@ function Storage(props: Props) {
const { hintIsHidden } = props;
const showStore = type !== STORAGE_TYPES.MOBX;
return (
<BottomBlock>
<BottomBlock.Header>
{list.length > 0 && (
<div className="flex w-full">
{showStore && (
<h3 style={{ width: '25%', marginRight: 20 }} className="font-semibold">
{'STATE'}
</h3>
)}
<h3 style={{ width: '25%', marginRight: 20 }} className="font-semibold">
{'STATE'}
</h3>
{showDiffs ? (
<h3 style={{ width: '39%' }} className="font-semibold">
DIFFS
@ -311,22 +329,17 @@ function Storage(props: Props) {
size="small"
show={list.length === 0}
>
{showStore && (
<div className="ph-10 scroll-y" style={{ width: '25%' }}>
{list.length === 0 ? (
<div className="color-gray-light font-size-16 mt-20 text-center">
{'Empty state.'}
</div>
) : (
<JSONTree collapsed={2} src={
listNow.length === 0
? decodeMessage(list[0]).state
: decodeMessage(listNow[listNow.length - 1]).state}
/>
)}
</div>
)}
<div className="flex" style={{ width: showStore ? '75%' : '100%' }}>
<div className="ph-10 scroll-y" style={{ width: '25%' }}>
{list.length === 0 ? (
<div className="color-gray-light font-size-16 mt-20 text-center">
{'Empty state.'}
</div>
) : (
<JSONTree collapsed={2} src={stateObject}
/>
)}
</div>
<div className="flex" style={{ width: '75%' }}>
<Autoscroll className="ph-10">
{decodedList.map((item: Record<string, any>, i: number) =>
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 { observer } from 'mobx-react-lite';
import AutoplayToggle from 'Shared/AutoplayToggle';
import { connect } from 'react-redux'
import { connect } from 'react-redux';
import Tab from 'Components/Session/Player/SharedComponents/Tab';
const localhostWarn = (project) => project + '_localhost_warn'
const localhostWarn = (project) => project + '_localhost_warn';
function SubHeader(props) {
const localhostWarnKey = localhostWarn(props.siteId)
const defaultLocalhostWarn = localStorage.getItem(localhostWarnKey) !== '1'
const [showWarningModal, setWarning] = React.useState(defaultLocalhostWarn);
const { player, store } = React.useContext(PlayerContext);
const {
width,
height,
location: currentLocation,
fetchList,
graphqlList,
resourceList,
exceptionsList,
eventList: eventsList,
endTime,
} = store.get();
const localhostWarnKey = localhostWarn(props.siteId);
const defaultLocalhostWarn = localStorage.getItem(localhostWarnKey) !== '1';
const [showWarningModal, setWarning] = React.useState(defaultLocalhostWarn);
const { player, store } = React.useContext(PlayerContext);
const { width, height, endTime, tabStates, currentTab, tabs } = store.get();
const enabledIntegration = useMemo(() => {
const { integrations } = props;
if (!integrations || !integrations.size) {
return false;
}
const currentLocation = tabStates[currentTab]?.location || '';
const resourceList = tabStates[currentTab]?.resourceList || [];
const exceptionsList = tabStates[currentTab]?.exceptionsList || [];
const eventsList = tabStates[currentTab]?.eventList || [];
const graphqlList = tabStates[currentTab]?.graphqlList || [];
const fetchList = tabStates[currentTab]?.fetchList || [];
return integrations.some((i) => i.token);
})
const enabledIntegration = useMemo(() => {
const { integrations } = props;
if (!integrations || !integrations.size) {
return false;
}
const mappedResourceList = resourceList
.filter((r) => r.isRed || r.isYellow)
.concat(fetchList.filter((i) => parseInt(i.status) >= 400))
.concat(graphqlList.filter((i) => parseInt(i.status) >= 400));
return integrations.some((i) => i.token);
});
const { showModal, hideModal } = useModal();
const mappedResourceList = resourceList
.filter((r) => r.isRed || r.isYellow)
.concat(fetchList.filter((i) => parseInt(i.status) >= 400))
.concat(graphqlList.filter((i) => parseInt(i.status) >= 400));
const location =
currentLocation && currentLocation.length > 70
? `${currentLocation.slice(0, 25)}...${currentLocation.slice(-40)}`
: currentLocation;
const { showModal, hideModal } = useModal();
const showReportModal = () => {
player.pause();
const xrayProps = {
currentLocation: currentLocation,
resourceList: mappedResourceList,
exceptionsList: exceptionsList,
eventsList: eventsList,
endTime: endTime,
const location =
currentLocation && currentLocation.length > 70
? `${currentLocation.slice(0, 25)}...${currentLocation.slice(-40)}`
: currentLocation;
const showReportModal = () => {
player.pause();
const xrayProps = {
currentLocation: currentLocation,
resourceList: mappedResourceList,
exceptionsList: exceptionsList,
eventsList: eventsList,
endTime: endTime,
};
showModal(
<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 =
location && /(localhost)|(127.0.0.1)|(0.0.0.0)/.test(location) && showWarningModal;
const closeWarning = () => {
localStorage.setItem(localhostWarnKey, '1')
setWarning(false)
}
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 && (
const showWarning =
location && /(localhost)|(127.0.0.1)|(0.0.0.0)/.test(location) && showWarningModal;
const closeWarning = () => {
localStorage.setItem(localhostWarnKey, '1');
setWarning(false);
};
return (
<>
<div
className="flex items-center cursor-pointer color-gray-medium text-sm p-1 hover:bg-active-blue hover:!underline rounded-md"
>
<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
className="ml-auto text-sm flex items-center color-gray-medium gap-2"
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 className="w-full px-4 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}
{tabs.map((tab, i) => (
<React.Fragment key={tab}>
<Tab
i={i}
tab={tab}
currentTab={tabs.length === 1 ? tab : currentTab}
changeTab={(changeTo) => player.changeTab(changeTo)}
/>
</React.Fragment>
))}
<div
className="ml-auto text-sm flex items-center color-gray-medium gap-2"
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>
<QueueControls />
</div>
</div>
</div>
);
<div>
<QueueControls />
</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) => ({
siteId: state.getIn(['site', 'siteId']),
integrations: state.getIn([ 'issues', 'list' ])
siteId: state.getIn(['site', 'siteId']),
integrations: state.getIn(['issues', 'list']),
}))(observer(SubHeader));

View file

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

View file

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

View file

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

View file

@ -22,7 +22,12 @@ const TABS = TAB_KEYS.map((tab) => ({ text: tab, key: tab }))
function StackEventPanel() {
const { player, store } = React.useContext(PlayerContext)
const jump = (t: number) => player.jump(t)
const { stackList: list, stackListNow: listNow } = store.get()
const { currentTab, tabStates } = store.get()
const {
stackList: list = [],
stackListNow: listNow = [],
} = tabStates[currentTab]
const {
sessionStore: { devTools },

View file

@ -38,7 +38,7 @@ export default class ListWalker<T extends Timed> {
if (this.list.length === 0) {
return null;
}
return this.list[ this.list.length - 1 ];
return this.list.slice(-1)[0];
}
get current(): T | null {
@ -108,7 +108,7 @@ export default class ListWalker<T extends Timed> {
/**
* @returns last message with the time <= t.
* 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 {
let key: string = "time"; //TODO
@ -130,6 +130,30 @@ export default class ListWalker<T extends Timed> {
return changed ? this.list[ this.p - 1 ] : null;
}
moveGetLastDebug(t: number, index?: number): T | null {
let key: string = "time"; //TODO
let val = t;
if (index) {
key = "_index";
val = index;
}
let changed = false;
while (this.p < this.length && this.list[this.p][key] <= val) {
this.moveNext()
changed = true;
}
while (this.p > 0 && this.list[ this.p - 1 ][key] > val) {
this.movePrev()
changed = true;
}
// console.log(this.list[this.p - 1])
return changed ? this.list[ this.p - 1 ] : null;
}
/**
* Moves over the messages starting from the current+1 to the last one with the time <= t
* applying callback on each of them

View file

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

View file

@ -1,71 +1,59 @@
// @ts-ignore
import { Decoder } from "syncod";
import { Decoder } from 'syncod';
import logger from 'App/logger';
import { TYPES as EVENT_TYPES } from 'Types/session/event';
import { Log } from 'Player';
import {
ResourceType,
getResourceFromResourceTiming,
getResourceFromNetworkRequest
} from 'Player'
import type { Store } from 'Player';
import type { Store, ILog } from 'Player';
import ListWalker from '../common/ListWalker';
import PagesManager from './managers/PagesManager';
import MouseMoveManager from './managers/MouseMoveManager';
import PerformanceTrackManager from './managers/PerformanceTrackManager';
import WindowNodeCounter from './managers/WindowNodeCounter';
import ActivityManager from './managers/ActivityManager';
import { MouseThrashing, MType } from "./messages";
import { isDOMType } from './messages/filters.gen';
import type {
Message,
SetPageLocation,
ConnectionInformation,
SetViewportSize,
SetViewportScroll,
MouseClick,
} from './messages';
import Lists, { INITIAL_STATE as LISTS_INITIAL_STATE, State as ListsState } from './Lists';
import { MouseThrashing, MType } from './messages';
import type { Message, MouseClick } from './messages';
import Screen, {
INITIAL_STATE as SCREEN_INITIAL_STATE,
State as ScreenState,
} from './Screen/Screen';
import type { InitialLists } from './Lists'
import type { PerformanceChartPoint } from './managers/PerformanceTrackManager';
import type { InitialLists } from './Lists';
import type { SkipInterval } from './managers/ActivityManager';
import TabSessionManager, { TabState } from 'Player/web/TabManager';
import ActiveTabManager from 'Player/web/managers/ActiveTabManager';
export interface State extends ScreenState, ListsState {
performanceChartData: PerformanceChartPoint[],
skipIntervals: SkipInterval[],
connType?: string,
connBandwidth?: number,
location?: string,
performanceChartTime?: number,
performanceAvailability?: PerformanceTrackManager['availability']
domContentLoadedTime?: { time: number, value: number },
domBuildingTime?: number,
loadTime?: { time: number, value: number },
error: boolean,
messagesLoading: boolean,
cssLoading: boolean,
ready: boolean,
lastMessageTime: number,
firstVisualEvent: number,
messagesProcessed: boolean,
interface RawList {
event: Record<string, any>[] & { tabId: string | null };
frustrations: Record<string, any>[] & { tabId: string | null };
stack: Record<string, any>[] & { tabId: string | null };
exceptions: ILog[];
}
export interface State extends ScreenState {
skipIntervals: SkipInterval[];
connType?: string;
connBandwidth?: number;
location?: string;
tabStates: {
[tabId: string]: TabState;
};
const visualChanges = [
domContentLoadedTime?: { time: number; value: number };
domBuildingTime?: number;
loadTime?: { time: number; value: number };
error: boolean;
messagesLoading: boolean;
ready: boolean;
lastMessageTime: number;
firstVisualEvent: number;
messagesProcessed: boolean;
currentTab: string;
tabs: string[];
tabChangeEvents: { tabId: string; timestamp: number; tabName: string }[];
}
export const visualChanges = [
MType.MouseMove,
MType.MouseClick,
MType.CreateElementNode,
@ -73,258 +61,208 @@ const visualChanges = [
MType.SetInputChecked,
MType.SetViewportSize,
MType.SetViewportScroll,
]
];
export default class MessageManager {
static INITIAL_STATE: State = {
...SCREEN_INITIAL_STATE,
...LISTS_INITIAL_STATE,
performanceChartData: [],
tabStates: {},
skipIntervals: [],
error: false,
cssLoading: false,
ready: false,
lastMessageTime: 0,
firstVisualEvent: 0,
messagesProcessed: false,
messagesLoading: false,
}
currentTab: '',
tabs: [],
tabChangeEvents: [],
};
private locationEventManager: ListWalker<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 mouseThrashingManager: ListWalker<MouseThrashing> = new ListWalker();
private resizeManager: ListWalker<SetViewportSize> = new ListWalker([]);
private pagesManager: PagesManager;
private activityManager: ActivityManager | null = null;
private mouseMoveManager: MouseMoveManager;
private scrollManager: ListWalker<SetViewportScroll> = new ListWalker();
private activeTabManager = new ActiveTabManager();
public readonly decoder = new Decoder();
private lists: Lists;
private activityManager: ActivityManager | null = null;
private readonly sessionStart: number;
private navigationStartOffset: number = 0;
private lastMessageTime: number = 0;
private firstVisualEventSet = false;
public readonly tabs: Record<string, TabSessionManager> = {};
private tabChangeEvents: Record<string, number>[] = [];
private activeTab = '';
constructor(
private readonly session: any /*Session*/,
private readonly state: Store<State>,
private readonly session: Record<string, any>,
private readonly state: Store<State & { time: number }>,
private readonly screen: Screen,
initialLists?: Partial<InitialLists>,
private readonly uiErrorHandler?: { error: (error: string) => void, },
private readonly initialLists?: Partial<InitialLists>,
private readonly uiErrorHandler?: { error: (error: string) => void }
) {
this.pagesManager = new PagesManager(screen, this.session.isMobile, this.setCSSLoading)
this.mouseMoveManager = new MouseMoveManager(screen)
this.sessionStart = this.session.startedAt
this.lists = new Lists(initialLists)
initialLists?.event?.forEach((e: Record<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
this.mouseMoveManager = new MouseMoveManager(screen);
this.sessionStart = this.session.startedAt;
this.activityManager = new ActivityManager(this.session.duration.milliseconds); // only if not-live
}
public getListsFullState = () => {
return this.lists.getFullListsState()
}
const fullState: Record<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>) {
Object.keys(lists).forEach((key: 'event' | 'stack' | 'exceptions') => {
const currentList = this.lists.lists[key]
lists[key]!.forEach(item => currentList.insert(item))
public updateLists(lists: RawList) {
Object.keys(this.tabs).forEach((tab) => {
this.tabs[tab]!.updateLists(lists);
// once upon a time we wanted to insert events for each tab individually
// but then evil magician came and said "no, you don't want to do that"
// because it was bad for database size
// const list = {
// event: lists.event.filter((e) => e.tabId === tab),
// frustrations: lists.frustrations.filter((e) => e.tabId === tab),
// stack: lists.stack.filter((e) => e.tabId === tab),
// exceptions: lists.exceptions.filter((e) => e.tabId === tab),
// };
// // saving some microseconds here probably
// if (Object.values(list).some((l) => l.length > 0)) {
// this.tabs[tab]!.updateLists(list);
// }
})
lists?.event?.forEach((e: Record<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[]) => {
// @ts-ignore Hack for upet (TODO: fix ordering in one mutation in tracker(removes first))
const headChildrenIds = msgs.filter(m => m.parentID === 1).map(m => m.id);
this.pagesManager.sortPages((m1, m2) => {
if (m1.time === m2.time) {
if (m1.tp === MType.RemoveNode && m2.tp !== MType.RemoveNode) {
if (headChildrenIds.includes(m1.id)) {
return -1;
}
} else if (m2.tp === MType.RemoveNode && m1.tp !== MType.RemoveNode) {
if (headChildrenIds.includes(m2.id)) {
return 1;
}
} else if (m2.tp === MType.RemoveNode && m1.tp === MType.RemoveNode) {
const m1FromHead = headChildrenIds.includes(m1.id);
const m2FromHead = headChildrenIds.includes(m2.id);
if (m1FromHead && !m2FromHead) {
return -1;
} else if (m2FromHead && !m1FromHead) {
return 1;
}
}
}
return 0;
})
}
Object.values(this.tabs).forEach((tab) => tab._sortMessagesHack(msgs));
};
private waitingForFiles: boolean = false
private waitingForFiles: boolean = false;
public onFileReadSuccess = () => {
const stateToUpdate : Partial<State>= {
performanceChartData: this.performanceTrackManager.chartData,
performanceAvailability: this.performanceTrackManager.availability,
...this.lists.getFullListsState(),
}
if (this.activityManager) {
this.activityManager.end()
stateToUpdate.skipIntervals = this.activityManager.list
this.activityManager.end();
this.state.update({ skipIntervals: this.activityManager.list });
}
this.state.update(stateToUpdate)
}
Object.values(this.tabs).forEach((tab) => tab.onFileReadSuccess?.());
};
public onFileReadFailed = (e: any) => {
logger.error(e)
this.state.update({ error: true })
this.uiErrorHandler?.error('Error requesting a session file')
}
logger.error(e);
this.state.update({ error: true });
this.uiErrorHandler?.error('Error requesting a session file');
};
public onFileReadFinally = () => {
this.waitingForFiles = false
this.state.update({ messagesProcessed: true })
}
this.waitingForFiles = false;
this.state.update({ messagesProcessed: true });
};
public startLoading = () => {
this.waitingForFiles = true
this.state.update({ messagesProcessed: false })
this.setMessagesLoading(true)
}
this.waitingForFiles = true;
this.state.update({ messagesProcessed: false });
this.setMessagesLoading(true);
};
resetMessageManagers() {
this.locationEventManager = new ListWalker();
this.locationManager = new ListWalker();
this.loadedLocationManager = new ListWalker();
this.connectionInfoManger = new ListWalker();
this.clickManager = new ListWalker();
this.scrollManager = new ListWalker();
this.resizeManager = new ListWalker();
this.performanceTrackManager = new PerformanceTrackManager()
this.windowNodeCounter = new WindowNodeCounter();
this.pagesManager = new PagesManager(this.screen, this.session.isMobile, this.setCSSLoading)
this.mouseMoveManager = new MouseMoveManager(this.screen);
this.activityManager = new ActivityManager(this.session.duration.milliseconds);
this.activeTabManager = new ActiveTabManager();
Object.values(this.tabs).forEach((tab) => tab.resetMessageManagers());
}
move(t: number, index?: number): void {
const stateToUpdate: Partial<State> = {};
/* == REFACTOR_ME == */
const lastLoadedLocationMsg = this.loadedLocationManager.moveGetLast(t, index);
if (!!lastLoadedLocationMsg) {
// TODO: page-wise resources list // setListsStartTime(lastLoadedLocationMsg.time)
this.navigationStartOffset = lastLoadedLocationMsg.navigationStart - this.sessionStart;
}
const llEvent = this.locationEventManager.moveGetLast(t, index);
if (!!llEvent) {
if (llEvent.domContentLoadedTime != null) {
stateToUpdate.domContentLoadedTime = {
time: llEvent.domContentLoadedTime + this.navigationStartOffset, //TODO: predefined list of load event for the network tab (merge events & SetPageLocation: add navigationStart to db)
value: llEvent.domContentLoadedTime,
}
}
if (llEvent.loadTime != null) {
stateToUpdate.loadTime = {
time: llEvent.loadTime + this.navigationStartOffset,
value: llEvent.loadTime,
}
}
if (llEvent.domBuildingTime != null) {
stateToUpdate.domBuildingTime = llEvent.domBuildingTime;
}
}
/* === */
const lastLocationMsg = this.locationManager.moveGetLast(t, index);
if (!!lastLocationMsg) {
stateToUpdate.location = lastLocationMsg.url;
}
const lastConnectionInfoMsg = this.connectionInfoManger.moveGetLast(t, index);
if (!!lastConnectionInfoMsg) {
stateToUpdate.connType = lastConnectionInfoMsg.type;
stateToUpdate.connBandwidth = lastConnectionInfoMsg.downlink;
}
const lastPerformanceTrackMessage = this.performanceTrackManager.moveGetLast(t, index);
if (!!lastPerformanceTrackMessage) {
stateToUpdate.performanceChartTime = lastPerformanceTrackMessage.time;
}
Object.assign(stateToUpdate, this.lists.moveGetState(t))
Object.keys(stateToUpdate).length > 0 && this.state.update(stateToUpdate);
/* Sequence of the managers is important here */
// Preparing the size of "screen"
const lastResize = this.resizeManager.moveGetLast(t, index);
if (!!lastResize) {
this.setSize(lastResize)
}
this.pagesManager.moveReady(t).then(() => {
const lastScroll = this.scrollManager.moveGetLast(t, index);
if (!!lastScroll && this.screen.window) {
this.screen.window.scrollTo(lastScroll.x, lastScroll.y);
}
move(t: number): any {
// usually means waiting for messages from live session
if (Object.keys(this.tabs).length === 0) return;
this.activeTabManager.moveReady(t).then((tabId) => {
// Moving mouse and setting :hover classes on ready view
this.mouseMoveManager.move(t);
const lastClick = this.clickManager.moveGetLast(t);
if (!!lastClick && t - lastClick.time < 600) { // happened during last 600ms
if (!!lastClick && t - lastClick.time < 600) {
// happened during last 600ms
this.screen.cursor.click();
}
const lastThrashing = this.mouseThrashingManager.moveGetLast(t)
const lastThrashing = this.mouseThrashingManager.moveGetLast(t);
if (!!lastThrashing && t - lastThrashing.time < 300) {
this.screen.cursor.shake();
}
})
if (this.waitingForFiles && this.lastMessageTime <= t && t !== this.session.duration.milliseconds) {
this.setMessagesLoading(true)
const activeTabs = this.state.get().tabs;
if (tabId && !activeTabs.includes(tabId)) {
this.state.update({ tabs: activeTabs.concat(tabId) });
}
if (tabId && this.activeTab !== tabId) {
this.state.update({ currentTab: tabId });
this.activeTab = tabId;
}
if (this.tabs[this.activeTab]) {
this.tabs[this.activeTab].move(t);
} else {
console.error(
'missing tab state',
this.tabs,
this.activeTab,
tabId,
this.activeTabManager.list
);
}
});
if (
this.waitingForFiles &&
this.lastMessageTime <= t &&
t !== this.session.duration.milliseconds
) {
this.setMessagesLoading(true);
}
}
public changeTab(tabId: string) {
this.activeTab = tabId;
this.state.update({ currentTab: tabId });
this.tabs[tabId].move(this.state.get().time);
}
distributeMessage = (msg: Message): void => {
const lastMessageTime = Math.max(msg.time, this.lastMessageTime)
this.lastMessageTime = lastMessageTime
this.state.update({ lastMessageTime })
public updateChangeEvents() {
this.state.update({ tabChangeEvents: this.tabChangeEvents });
}
distributeMessage = (msg: Message & { tabId: string }): void => {
if (!this.tabs[msg.tabId]) {
this.tabs[msg.tabId] = new TabSessionManager(
this.session,
this.state,
this.screen,
msg.tabId,
this.setSize,
this.sessionStart,
this.initialLists
);
}
const lastMessageTime = Math.max(msg.time, this.lastMessageTime);
this.lastMessageTime = lastMessageTime;
this.state.update({ lastMessageTime });
if (visualChanges.includes(msg.tp)) {
this.activityManager?.updateAcctivity(msg.time);
}
switch (msg.tp) {
case MType.SetPageLocation:
this.locationManager.append(msg);
if (msg.navigationStart > 0) {
this.loadedLocationManager.append(msg);
case MType.TabChange:
const prevChange = this.activeTabManager.last;
if (!prevChange || prevChange.tabId !== msg.tabId) {
this.tabChangeEvents.push({
tabId: msg.tabId,
timestamp: this.sessionStart + msg.time,
toTab: mapTabs(this.tabs)[msg.tabId],
fromTab: prevChange?.tabId ? mapTabs(this.tabs)[prevChange.tabId] : '',
type: 'TABCHANGE',
});
this.activeTabManager.append(msg);
}
break;
case MType.SetViewportSize:
this.resizeManager.append(msg);
break;
case MType.MouseThrashing:
this.mouseThrashingManager.append(msg);
break;
@ -334,103 +272,37 @@ export default class MessageManager {
case MType.MouseClick:
this.clickManager.append(msg);
break;
case MType.SetViewportScroll:
this.scrollManager.append(msg);
break;
case MType.PerformanceTrack:
this.performanceTrackManager.append(msg);
break;
case MType.SetPageVisibility:
this.performanceTrackManager.handleVisibility(msg)
break;
case MType.ConnectionInformation:
this.connectionInfoManger.append(msg);
break;
case MType.OTable:
this.decoder.set(msg.key, msg.value);
break;
/* Lists: */
case MType.ConsoleLog:
if (msg.level === 'debug') break;
this.lists.lists.log.append(
// @ts-ignore : TODO: enums in the message schema
Log(msg)
)
break;
case MType.ResourceTimingDeprecated:
case MType.ResourceTiming:
// TODO: merge `resource` and `fetch` lists into one here instead of UI
if (msg.initiator !== ResourceType.FETCH && msg.initiator !== ResourceType.XHR) {
// @ts-ignore TODO: typing for lists
this.lists.lists.resource.insert(getResourceFromResourceTiming(msg, this.sessionStart))
}
break;
case MType.Fetch:
case MType.NetworkRequest:
this.lists.lists.fetch.insert(getResourceFromNetworkRequest(msg, this.sessionStart))
break;
case MType.Redux:
this.lists.lists.redux.append(msg);
break;
case MType.NgRx:
this.lists.lists.ngrx.append(msg);
break;
case MType.Vuex:
this.lists.lists.vuex.append(msg);
break;
case MType.Zustand:
this.lists.lists.zustand.append(msg)
break
case MType.MobX:
this.lists.lists.mobx.append(msg);
break;
case MType.GraphQl:
this.lists.lists.graphql.append(msg);
break;
case MType.Profiler:
this.lists.lists.profiles.append(msg);
break;
/* ===|=== */
default:
switch (msg.tp) {
case MType.CreateDocument:
if (!this.firstVisualEventSet) {
this.state.update({ firstVisualEvent: msg.time });
this.activeTabManager.append({ tp: MType.TabChange, tabId: msg.tabId, time: 0 });
this.state.update({
firstVisualEvent: msg.time,
currentTab: msg.tabId,
tabs: [msg.tabId],
});
this.firstVisualEventSet = true;
}
this.windowNodeCounter.reset();
this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count);
break;
case MType.CreateTextNode:
case MType.CreateElementNode:
this.windowNodeCounter.addNode(msg.id, msg.parentID);
this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count);
break;
case MType.MoveNode:
this.windowNodeCounter.moveNode(msg.id, msg.parentID);
this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count);
break;
case MType.RemoveNode:
this.windowNodeCounter.removeNode(msg.id);
this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count);
break;
}
this.performanceTrackManager.addNodeCountPointIfNeed(msg.time)
isDOMType(msg.tp) && this.pagesManager.appendMessage(msg)
this.tabs[msg.tabId].distributeMessage(msg);
break;
}
}
};
setMessagesLoading = (messagesLoading: boolean) => {
if (!messagesLoading) {
this.updateChangeEvents();
}
this.screen.display(!messagesLoading);
this.state.update({ messagesLoading, ready: !messagesLoading && !this.state.get().cssLoading });
}
};
decodeMessage(msg: Message) {
return this.decoder.decode(msg)
return this.tabs[this.activeTab].decodeMessage(msg);
}
private setSize({ height, width }: { height: number, width: number }) {
private setSize({ height, width }: { height: number; width: number }) {
this.screen.scale({ height, width });
this.state.update({ width, height });
}
@ -438,8 +310,15 @@ export default class MessageManager {
// TODO: clean managers?
clean() {
this.state.update(MessageManager.INITIAL_STATE);
// @ts-ignore
this.pagesManager.reset();
}
}
function mapTabs(tabs: Record<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,
wpState,
messageManager,
isClickMap
isClickMap,
uiErrorHandler
)
super(wpState, messageManager)
this.screen = screen
@ -82,7 +83,7 @@ export default class WebPlayer extends Player {
}
updateLists = (session: any) => {
let lists = {
const lists = {
event: session.events || [],
frustrations: session.frustrations || [],
stack: session.stackEvents || [],
@ -162,6 +163,10 @@ export default class WebPlayer extends Player {
this.screen.cursor.showTag(name)
}
changeTab = (tab: string) => {
this.messageManager.changeTab(tab)
}
clean = () => {
super.clean()
this.screen.clean()

View file

@ -166,9 +166,10 @@ export default class AssistManager {
waitingForMessages = true
this.setStatus(ConnectionStatus.WaitingMessages) // TODO: reconnect happens frequently on bad network
})
socket.on('messages', messages => {
jmr.append(messages) // as RawMessage[]
let currentTab = ''
socket.on('messages', messages => {
jmr.append(messages.data) // as RawMessage[]
if (waitingForMessages) {
waitingForMessages = false // TODO: more explicit
this.setStatus(ConnectionStatus.Connected)
@ -185,9 +186,15 @@ export default class AssistManager {
this.setStatus(ConnectionStatus.Connected)
})
socket.on('UPDATE_SESSION', ({ active }) => {
socket.on('UPDATE_SESSION', (evData) => {
const { metadata = {}, data = {} } = evData
const { tabId } = metadata
const { active } = data
this.clearDisconnectTimeout()
!this.inactiveTimeout && this.setStatus(ConnectionStatus.Connected)
if (tabId !== currentTab) {
this.store.update({ currentTab: tabId })
}
if (typeof active === "boolean") {
this.clearInactiveTimeout()
if (active) {

View file

@ -18,6 +18,7 @@ export enum CallingState {
export interface State {
calling: CallingState;
currentTab?: string;
}
export default class Call {
@ -158,7 +159,7 @@ export default class Call {
}
initiateCallEnd = async () => {
this.socket?.emit("call_end", appStore.getState().getIn([ 'user', 'account', 'name']))
this.emitData("call_end", appStore.getState().getIn([ 'user', 'account', 'name']))
this.handleCallEnd()
// TODO: We have it separated, right? (check)
// const remoteControl = this.store.get().remoteControl
@ -168,6 +169,10 @@ export default class Call {
// }
}
private emitData = (event: string, data?: any) => {
this.socket?.emit(event, { meta: { tabId: this.store.get().currentTab }, data })
}
private callArgs: {
localStream: LocalStream,
@ -206,7 +211,7 @@ export default class Call {
toggleVideoLocalStream(enabled: boolean) {
this.getPeer().then((peer) => {
this.socket.emit('videofeed', { streamId: peer.id, enabled })
this.emitData('videofeed', { streamId: peer.id, enabled })
})
}
@ -223,7 +228,7 @@ export default class Call {
if (![CallingState.NoCall, CallingState.Reconnecting].includes(this.store.get().calling)) { return }
this.store.update({ calling: CallingState.Connecting })
this._peerConnection(this.peerID);
this.socket.emit("_agent_name", appStore.getState().getIn([ 'user', 'account', 'name']))
this.emitData("_agent_name", appStore.getState().getIn([ 'user', 'account', 'name']))
}
private async _peerConnection(remotePeerId: string) {

View file

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

View file

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

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

View file

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

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

View file

@ -3,7 +3,7 @@
import { MType } from './raw.gen'
const DOM_TYPES = [0,4,5,6,7,8,9,10,11,12,13,14,15,16,18,19,20,37,38,49,50,51,54,55,57,58,59,60,61,67,69,70,71,72,73,74,75,76,77,113,114,90,93,96,100,102,103,105]
const DOM_TYPES = [0,4,5,6,7,8,9,10,11,12,13,14,15,16,18,19,20,37,38,49,50,51,54,55,57,58,59,60,61,67,69,70,71,72,73,74,75,76,77,113,114,117,118,90,93,96,100,102,103,105]
export function isDOMType(t: MType) {
return DOM_TYPES.includes(t)
}

View file

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

View file

@ -56,6 +56,8 @@ export const enum MType {
SelectionChange = 113,
MouseThrashing = 114,
ResourceTiming = 116,
TabChange = 117,
TabData = 118,
IosSessionStart = 90,
IosCustomEvent = 93,
IosScreenChanges = 96,
@ -447,6 +449,16 @@ export interface RawResourceTiming {
cached: boolean,
}
export interface RawTabChange {
tp: MType.TabChange,
tabId: string,
}
export interface RawTabData {
tp: MType.TabData,
tabId: string,
}
export interface RawIosSessionStart {
tp: MType.IosSessionStart,
timestamp: number,
@ -518,4 +530,4 @@ export interface RawIosNetworkCall {
}
export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequest | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTimingDeprecated | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawSelectionChange | RawMouseThrashing | RawResourceTiming | RawIosSessionStart | RawIosCustomEvent | RawIosScreenChanges | RawIosClickEvent | RawIosPerformanceEvent | RawIosLog | RawIosNetworkCall;
export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequest | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTimingDeprecated | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawSelectionChange | RawMouseThrashing | RawResourceTiming | RawTabChange | RawTabData | RawIosSessionStart | RawIosCustomEvent | RawIosScreenChanges | RawIosClickEvent | RawIosPerformanceEvent | RawIosLog | RawIosNetworkCall;

View file

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

View file

@ -470,8 +470,18 @@ type TrResourceTiming = [
cached: boolean,
]
type TrTabChange = [
type: 117,
tabId: string,
]
export type TrackerMessage = TrTimestamp | TrSetPageLocation | TrSetViewportSize | TrSetViewportScroll | TrCreateDocument | TrCreateElementNode | TrCreateTextNode | TrMoveNode | TrRemoveNode | TrSetNodeAttribute | TrRemoveNodeAttribute | TrSetNodeData | TrSetNodeScroll | TrSetInputTarget | TrSetInputValue | TrSetInputChecked | TrMouseMove | TrNetworkRequest | TrConsoleLog | TrPageLoadTiming | TrPageRenderTiming | TrCustomEvent | TrUserID | TrUserAnonymousID | TrMetadata | TrCSSInsertRule | TrCSSDeleteRule | TrFetch | TrProfiler | TrOTable | TrStateAction | TrRedux | TrVuex | TrMobX | TrNgRx | TrGraphQL | TrPerformanceTrack | TrStringDict | TrSetNodeAttributeDict | TrResourceTimingDeprecated | TrConnectionInformation | TrSetPageVisibility | TrLoadFontFace | TrSetNodeFocus | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner | TrJSException | TrZustand | TrBatchMetadata | TrPartitionedMessage | TrInputChange | TrSelectionChange | TrMouseThrashing | TrUnbindNodes | TrResourceTiming
type TrTabData = [
type: 118,
tabId: string,
]
export type TrackerMessage = TrTimestamp | TrSetPageLocation | TrSetViewportSize | TrSetViewportScroll | TrCreateDocument | TrCreateElementNode | TrCreateTextNode | TrMoveNode | TrRemoveNode | TrSetNodeAttribute | TrRemoveNodeAttribute | TrSetNodeData | TrSetNodeScroll | TrSetInputTarget | TrSetInputValue | TrSetInputChecked | TrMouseMove | TrNetworkRequest | TrConsoleLog | TrPageLoadTiming | TrPageRenderTiming | TrCustomEvent | TrUserID | TrUserAnonymousID | TrMetadata | TrCSSInsertRule | TrCSSDeleteRule | TrFetch | TrProfiler | TrOTable | TrStateAction | TrRedux | TrVuex | TrMobX | TrNgRx | TrGraphQL | TrPerformanceTrack | TrStringDict | TrSetNodeAttributeDict | TrResourceTimingDeprecated | TrConnectionInformation | TrSetPageVisibility | TrLoadFontFace | TrSetNodeFocus | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner | TrJSException | TrZustand | TrBatchMetadata | TrPartitionedMessage | TrInputChange | TrSelectionChange | TrMouseThrashing | TrUnbindNodes | TrResourceTiming | TrTabChange | TrTabData
export default function translate(tMsg: TrackerMessage): RawMessage | null {
switch(tMsg[0]) {
@ -940,6 +950,20 @@ export default function translate(tMsg: TrackerMessage): RawMessage | null {
}
}
case 117: {
return {
tp: MType.TabChange,
tabId: tMsg[1],
}
}
case 118: {
return {
tp: MType.TabData,
tabId: tMsg[1],
}
}
default:
return null
}

View file

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

View file

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

View file

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

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_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 index1 = 0;
let index2 = 0;

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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",
"compilerOptions": {
"module": "CommonJS",
"outDir": "./cjs"
"outDir": "./cjs",
"rootDir": "src"
},
"exclude": ["**/*.test.ts"]
}

View file

@ -8,6 +8,8 @@
"moduleResolution": "node",
"allowSyntheticDefaultImports": 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
- Prevent auto restart after manual stop

View file

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

View file

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

View file

@ -68,6 +68,8 @@ export declare const enum Type {
MouseThrashing = 114,
UnbindNodes = 115,
ResourceTiming = 116,
TabChange = 117,
TabData = 118,
}
@ -536,6 +538,16 @@ export type ResourceTiming = [
/*cached:*/ boolean,
]
export type TabChange = [
/*type:*/ Type.TabChange,
/*tabId:*/ string,
]
type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequest | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTimingDeprecated | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | InputChange | SelectionChange | MouseThrashing | UnbindNodes | ResourceTiming
export type TabData = [
/*type:*/ Type.TabData,
/*tabId:*/ string,
]
type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequest | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTimingDeprecated | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | InputChange | SelectionChange | MouseThrashing | UnbindNodes | ResourceTiming | TabChange | TabData
export default Message

View file

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

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

View file

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

View file

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

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
}
export function isIframeCrossdomain(e: HTMLIFrameElement): boolean {
try {
return e.contentWindow?.location.href !== window.location.href
} catch (e) {
return true
}
}
/**
* checks if iframe is accessible
**/
@ -100,3 +92,16 @@ export function canAccessIframe(iframe: HTMLIFrameElement) {
return false
}
}
function dec2hex(dec: number) {
return dec.toString(16).padStart(2, '0')
}
export function generateRandomId(len?: number) {
const arr: Uint8Array = new Uint8Array((len || 40) / 2)
// msCrypto = IE11
// @ts-ignore
const safeCrypto = window.crypto || window.msCrypto
safeCrypto.getRandomValues(arr)
return Array.from(arr, dec2hex).join('')
}

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

View file

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

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

View file

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