addunit tests for session events parser

This commit is contained in:
Андрей Бабушкин 2025-05-21 16:45:14 +02:00
parent a06f035b5a
commit d09253c08f
17 changed files with 1238 additions and 59 deletions

View file

@ -1,3 +1,5 @@
import { TextDecoder } from 'util';
export default class PrimitiveReader {
/** pointer for curent position in the buffer */
protected p: number = 0;

View file

@ -1,6 +1,5 @@
import { DateTime, Interval, Settings } from 'luxon';
import Record from 'Types/Record';
import { roundToNextMinutes } from '@/utils';
export const LAST_30_MINUTES = 'LAST_30_MINUTES';
export const TODAY = 'TODAY';

View file

@ -69,74 +69,77 @@ interface IosCrash {
export interface ISession {
sessionId: string;
pageTitle: string;
active: boolean;
siteId: string;
projectKey: string;
peerId: string;
live: boolean;
startedAt: number;
pageTitle?: string;
active?: boolean;
siteId?: string;
projectKey?: string;
peerId?: string;
live?: boolean;
startedAt?: number;
duration: number;
durationMs: number;
events: InjectedEvent[];
crashes: IosCrash[];
stackEvents: StackEvent[];
metadata: [];
favorite: boolean;
durationMs?: number;
events?: InjectedEvent[];
crashes?: IosCrash[];
stackEvents?: StackEvent[];
metadata: {
userId?: string | null;
demo?: string | null;
};
favorite?: boolean;
filterId?: string;
canvasURL: string[];
domURL: string[];
devtoolsURL: string[];
uxtVideo: string[];
canvasURL?: string[];
domURL?: string[];
devtoolsURL?: string[];
uxtVideo?: string[];
/**
* @deprecated
*/
mobsUrl: string[];
mobsUrl?: string[];
userBrowser: string;
userBrowserVersion: string;
userBrowserVersion?: string;
userCountry: string;
userCity: string;
userState: string;
userDevice: string;
userDevice?: string | null;
userDeviceType: string;
isMobile: boolean;
isMobile?: boolean;
userOs: string;
userOsVersion: string;
userOsVersion?: string;
userId: string;
userAnonymousId: string;
userAnonymousId?: string | null;
userUuid: string;
userDisplayName: string;
userNumericHash: number;
userDisplayName?: string;
userNumericHash?: number;
viewed: boolean;
consoleLogCount: number;
consoleLogCount?: number;
eventsCount: number;
pagesCount: number;
errorsCount: number;
issueTypes: string[];
issues: IIssue[];
referrer: string | null;
userDeviceHeapSize: number;
userDeviceMemorySize: number;
errors: SessionError[];
socket: string;
isIOS: boolean;
revId: string | null;
issues?: IIssue[];
referrer?: string | null;
userDeviceHeapSize?: number;
userDeviceMemorySize?: number;
errors?: SessionError[];
socket?: string;
isIOS?: boolean;
revId?: string | null;
agentIds?: string[];
isCallActive?: boolean;
agentToken: string;
notes: Note[];
notesWithEvents: Array<Note | InjectedEvent>;
fileKey: string;
agentToken?: string;
notes?: Note[];
notesWithEvents?: Array<Note | InjectedEvent>;
fileKey?: string;
platform: 'web' | 'ios' | 'android';
projectId: string;
projectId: number | string;
startTs: number;
timestamp: number;
backendErrors: number;
consoleErrors: number;
timestamp?: number;
backendErrors?: number;
consoleErrors?: number;
sessionID?: string;
userID: string;
userUUID: string;
userEvents: any[];
userID?: string;
userUUID?: string;
userEvents?: any[];
timezone?: string;
videoURL?: string[];
isMobileNative?: boolean;

View file

@ -5,6 +5,14 @@ export default {
'^Types/(.+)$': '<rootDir>/app/types/$1',
'^App/(.+)$': '<rootDir>/app/$1',
"\\.(css|less)$": "<rootDir>/tests/mocks/style.mock.js",
'^@/(.*)$': '<rootDir>/app/$1',
'^Player/(.+)$': '<rootDir>/app/player/$1',
'^Player$': '<rootDir>/app/player',
'^UI/(.+)$': '<rootDir>/app/components/ui/$1',
'^UI$': '<rootDir>/app/components/ui',
'^Shared/(.+)$': '<rootDir>/app/components/shared/$1',
'\\.svg$': '<rootDir>/tests/mocks/svgMock.js',
'^Components/(.+)$': '<rootDir>/app/components/$1',
},
collectCoverage: true,
verbose: true,
@ -23,4 +31,5 @@ export default {
transformIgnorePatterns: [
'/node_modules/(?!syncod)',
],
setupFiles: ['<rootDir>/tests/jest.setup.ts'],
};

View file

@ -107,6 +107,7 @@
"@openreplay/sourcemap-uploader": "^3.0.10",
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/eslint-plugin-jsx-a11y": "^6",
"@types/jest": "^29.5.14",
"@types/luxon": "^3.4.2",
"@types/node": "^22.7.8",
"@types/prismjs": "^1",
@ -136,7 +137,7 @@
"eslint-plugin-react": "^7.29.4",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.6.3",
"jest": "^29.5.0",
"jest": "^29.7.0",
"mini-css-extract-plugin": "^2.9.2",
"minio": "^7.1.3",
"node-gyp": "^9.0.0",
@ -154,7 +155,7 @@
"svgo": "^2.8.0",
"tailwindcss": "^3.4.17",
"thread-loader": "^4.0.4",
"ts-jest": "^29.0.5",
"ts-jest": "^29.3.3",
"ts-node": "^10.7.0",
"typescript": "^4.9.5",
"typescript-eslint": "^8.32.1",

View file

@ -0,0 +1,83 @@
import { describe, test, expect, beforeEach, jest } from '@jest/globals';
import ListWalker from '../app/player/common/ListWalker';
import type { Timed } from '../app/player/common/types';
interface Item extends Timed {
value?: string;
}
describe('ListWalker', () => {
let walker: ListWalker<Item>;
beforeEach(() => {
walker = new ListWalker<Item>([]);
jest.spyOn(console, 'error').mockImplementation(() => {});
});
test('append maintains order and prevents out of order inserts', () => {
walker.append({ time: 1 });
walker.append({ time: 3 });
expect(walker.list.map(i => i.time)).toEqual([1, 3]);
walker.append({ time: 2 });
expect(walker.list.map(i => i.time)).toEqual([1, 3]);
expect((console.error as jest.Mock).mock.calls.length).toBe(1);
});
test('unshift prepends items', () => {
walker.append({ time: 2 });
walker.unshift({ time: 1 });
expect(walker.list.map(i => i.time)).toEqual([1, 2]);
});
test('insert places item according to time', () => {
walker.append({ time: 1 });
walker.append({ time: 3 });
walker.insert({ time: 2 });
expect(walker.list.map(i => i.time)).toEqual([1, 2, 3]);
});
test('moveGetLast advances pointer and returns item', () => {
walker = new ListWalker<Item>([{ time: 1 }, { time: 3 }, { time: 5 }]);
expect(walker.moveGetLast(3)?.time).toBe(3);
expect(walker.countNow).toBe(2);
expect(walker.moveGetLast(3)).toBeNull();
expect(walker.moveGetLast(4)).toBeNull();
expect(walker.moveGetLast(4, undefined, true)?.time).toBe(3);
expect(walker.moveGetLast(1)?.time).toBe(1);
expect(walker.countNow).toBe(1);
});
test('getNew returns items when pointer moves or time decreases', () => {
walker = new ListWalker<Item>([{ time: 1 }, { time: 3 }, { time: 5 }]);
expect(walker.getNew(2)?.time).toBe(1);
expect(walker.getNew(4)?.time).toBe(3);
expect(walker.getNew(4)).toBeNull();
expect(walker.getNew(1)?.time).toBe(1);
});
test('findLast performs binary search', () => {
walker = new ListWalker<Item>([{ time: 1 }, { time: 3 }, { time: 5 }]);
expect(walker.findLast(4)?.time).toBe(3);
expect(walker.findLast(5)?.time).toBe(5);
expect(walker.findLast(0)).toBeNull();
});
test('moveApply iterates over items up to time', () => {
walker = new ListWalker<Item>([{ time: 1 }, { time: 2 }, { time: 3 }]);
const collected: number[] = [];
walker.moveApply(2.5, (m) => collected.push(m.time));
expect(collected).toEqual([1, 2]);
expect(walker.countNow).toBe(2);
walker.moveApply(1.5, (m) => collected.push(m.time));
expect(collected).toEqual([1, 2, 1]);
expect(walker.countNow).toBe(1);
});
});

View file

@ -0,0 +1,47 @@
import { describe, test, expect } from '@jest/globals';
import MFileReader from '../app/player/web/messages/MFileReader';
import { MType } from '../app/player/web/messages/raw.gen';
function encodeUint(value: number): Uint8Array {
const bytes: number[] = [];
let v = value;
do {
let byte = v & 0x7f;
v >>>= 7;
if (v) byte |= 0x80;
bytes.push(byte);
} while (v);
return Uint8Array.from(bytes);
}
function concat(...parts: Uint8Array[]): Uint8Array {
const total = parts.reduce((s, p) => s + p.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const p of parts) {
out.set(p, offset);
offset += p.length;
}
return out;
}
describe('MFileReader', () => {
test('checkForIndexes detects missing indexes and skips header', () => {
const data = new Uint8Array(9).fill(0xff);
const reader = new MFileReader(data);
reader.checkForIndexes();
expect(reader['noIndexes']).toBe(true);
expect(reader['p']).toBe(8);
reader.checkForIndexes();
expect(reader['p']).toBe(8);
});
test('readNext returns timestamp message and sets startTime', () => {
const data = concat(encodeUint(MType.Timestamp), encodeUint(2000));
const reader = new MFileReader(data);
reader['noIndexes'] = true;
const msg = reader.readNext();
expect(msg).toEqual({ tp: 9999, tabId: '', time: 0 });
expect(reader['startTime']).toBe(2000);
});
});

View file

@ -0,0 +1,218 @@
import { describe, expect, test, jest, beforeEach } from '@jest/globals';
import MessageLoader from '../app/player/web/MessageLoader';
import { MType } from '../app/player/web/messages';
import fs from 'fs';
import path from 'path';
import { TextDecoder } from 'util';
const loadFilesMock = jest.fn(async () => {});
jest.mock('../app/player/web/network/loadFiles', () => ({
__esModule: true,
loadFiles: jest.fn(async () => {}),
requestTarball: jest.fn(),
requestEFSDom: jest.fn(),
requestEFSDevtools: jest.fn(),
}));
const decryptSessionBytesMock = jest.fn((b: Uint8Array) => Promise.resolve(b));
jest.mock('../app/player/web/network/crypto', () => ({
__esModule: true,
decryptSessionBytes: jest.fn((b: Uint8Array) => Promise.resolve(b)),
}));
jest.mock('Player/common/unpack', () => ({
__esModule: true,
default: jest.fn((b: Uint8Array) => b),
}));
jest.mock('Player/common/tarball', () => ({
__esModule: true,
default: jest.fn((b: Uint8Array) => b),
}));
import MFileReader from '../app/player/web/messages/MFileReader';
const readNextMock = jest.fn();
jest.mock('../app/player/web/messages/MFileReader', () => {
return {
__esModule: true,
default: jest.fn().mockImplementation(() => {
return {
append: jest.fn(),
checkForIndexes: jest.fn(),
readNext: readNextMock,
};
}),
};
});
import { mockSession } from './mocks/sessionData';
const createStore = () => {
const state: Record<string, any> = {};
return {
get: () => state,
update: jest.fn((s: any) => Object.assign(state, s)),
updateTabStates: jest.fn(),
};
};
const createManager = () => ({
distributeMessage: jest.fn(),
sortDomRemoveMessages: jest.fn(),
setMessagesLoading: jest.fn(),
startLoading: jest.fn(),
getListsFullState: jest.fn(() => ({ list: true })),
createTabCloseEvents: jest.fn(),
onFileReadFinally: jest.fn(),
onFileReadSuccess: jest.fn(),
onFileReadFailed: jest.fn(),
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('MessageLoader.loadDomFiles', () => {
test('loads dom files and updates store', async () => {
const session = mockSession({});
const store = createStore();
const loader = new MessageLoader(
session,
store as any,
createManager() as any,
false,
);
const parser = jest.fn();
await loader.loadDomFiles(['u1', 'u2'], parser);
expect(store.update).toHaveBeenNthCalledWith(1, { domLoading: true });
expect(store.update).toHaveBeenNthCalledWith(2, { domLoading: false });
});
test('skips when no urls provided', async () => {
const loader = new MessageLoader(
mockSession({}),
createStore() as any,
createManager() as any,
false,
);
const parser = jest.fn();
await loader.loadDomFiles([], parser);
expect(loadFilesMock).not.toHaveBeenCalled();
});
});
describe('MessageLoader.loadDevtools', () => {
test('loads devtools when not clickmap', async () => {
const session = mockSession({});
session.devtoolsURL = ['d1'];
const store = createStore();
const manager = createManager();
const loader = new MessageLoader(
session,
store as any,
manager as any,
false,
);
const parser = jest.fn();
await loader.loadDevtools(parser);
expect(store.update).toHaveBeenCalledWith({ devtoolsLoading: true });
expect(store.update).toHaveBeenLastCalledWith({
...manager.getListsFullState(),
devtoolsLoading: false,
});
});
test('skips devtools for clickmap', async () => {
const session = mockSession({});
session.devtoolsURL = ['d1'];
const loader = new MessageLoader(
session,
createStore() as any,
createManager() as any,
true,
);
await loader.loadDevtools(jest.fn());
expect(loadFilesMock).not.toHaveBeenCalled();
});
});
describe('MessageLoader.createTabCloseEvents', () => {
test('delegates to manager when method exists', () => {
const manager = createManager();
const loader = new MessageLoader(
mockSession({}),
createStore() as any,
manager as any,
false,
);
loader.createTabCloseEvents();
expect(manager.createTabCloseEvents).toHaveBeenCalled();
});
});
describe('MessageLoader.preloadFirstFile', () => {
test('stores key and marks as preloaded', async () => {
const loader = new MessageLoader(
mockSession({}),
createStore() as any,
createManager() as any,
false,
);
const parser = jest.fn(() => Promise.resolve());
jest.spyOn(loader, 'createNewParser').mockReturnValue(parser);
await loader.preloadFirstFile(new Uint8Array([1]), 'key');
expect(loader.session.fileKey).toBe('key');
expect(parser).toHaveBeenCalled();
expect(loader.preloaded).toBe(true);
expect(loader.mobParser).toBe(parser);
});
});
describe('MessageLoader.createNewParser', () => {
test('parses messages and sorts them', async () => {
const loader = new MessageLoader(
mockSession({}),
createStore() as any,
createManager() as any,
false,
);
const msgs = [
{ tp: MType.SetNodeAttribute, time: 2 },
{ tp: MType.SetNodeAttribute, time: 1 },
];
readNextMock
.mockReturnValueOnce(msgs[0])
.mockReturnValueOnce(msgs[1])
.mockReturnValueOnce(null);
const onDone = jest.fn();
const parser = loader.createNewParser(false, onDone, 'file');
await parser(new Uint8Array());
expect(onDone).toHaveBeenCalledWith([msgs[1], msgs[0]], 'file 1');
expect(loader.rawMessages.length).toBe(2);
});
});
describe('MFileReader', () => {
test('creates a 9999 message from timestamp', () => {
const filePath = path.resolve('../frontend/tests/mocks/dom.mobe');
const mob = new Uint8Array(fs.readFileSync(filePath));
const reader = new MFileReader(mob, undefined);
reader.checkForIndexes();
console.log('reader', reader.readNext());
const msg = reader.readNext();
expect(msg?.tp).toEqual(9999);
});
});

View file

@ -0,0 +1,101 @@
import { it, expect, beforeEach, jest } from '@jest/globals';
import TabSessionManager from '../app/player/web/TabManager';
import SimpleStore from '../app/player/common/SimpleStore';
import { TYPES as EVENT_TYPES } from '../app/types/session/event';
import { MType } from '../app/player/web/messages/raw.gen';
jest.mock('@medv/finder', () => ({ default: jest.fn(() => 'mocked network-proxy content') }));
jest.mock('syncod', () => {
return {
Decoder: jest.fn().mockImplementation(() => ({ decode: jest.fn(), set: jest.fn() })),
};
});
jest.mock('js-untar', () => ({
__esModule: true,
default: jest.fn(),
}));
class FakeScreen {
displayFrame = jest.fn();
window: any = null;
document: any = { body: { querySelector: jest.fn(), style: {} } };
}
const session = { isMobile: false } as any;
const setSize = jest.fn();
let store: SimpleStore<any>;
let manager: TabSessionManager;
beforeEach(() => {
jest.useFakeTimers();
store = new SimpleStore({
tabStates: { tab1: { ...TabSessionManager.INITIAL_STATE } },
tabNames: {},
eventCount: 0,
});
manager = new TabSessionManager(session, store as any, new FakeScreen() as any, 'tab1', setSize, 0);
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('updateLists should append location events and update store', () => {
const event = { time: 1, key: 1, type: EVENT_TYPES.LOCATION } as any;
manager.updateLists({ event: [event] });
// @ts-ignore private access
expect((manager as any).locationEventManager.list[0]).toBe(event);
expect(store.get().tabStates['tab1'].eventList).toEqual([event]);
expect(store.get().eventCount).toBe(1);
});
it('resetMessageManagers should clear managers', () => {
// @ts-ignore private access
(manager as any).locationEventManager.append({ time: 1 });
// @ts-ignore private access
(manager as any).scrollManager.append({ time: 2 });
const oldPages = (manager as any).pagesManager;
manager.resetMessageManagers();
// @ts-ignore private access
expect((manager as any).locationEventManager.list.length).toBe(0);
// @ts-ignore private access
expect((manager as any).scrollManager.list.length).toBe(0);
// @ts-ignore private access
expect((manager as any).pagesManager).not.toBe(oldPages);
});
it('onFileReadSuccess should update store with lists and performance data', () => {
(manager as any).performanceTrackManager['chart'] = [{ time: 1, usedHeap: 0, totalHeap: 0, fps: null, cpu: null, nodesCount: 0 }];
(manager as any).performanceTrackManager['cpuAvailable'] = true;
(manager as any).performanceTrackManager['fpsAvailable'] = true;
manager.locationManager.append({ time: 2, url: 'http://example.com' } as any);
manager.onFileReadSuccess();
const state = store.get().tabStates['tab1'];
expect(state.performanceChartData.length).toBe(1);
expect(state.performanceAvailability).toEqual({ cpu: true, fps: true, heap: false, nodes: true });
expect(state.urlsList[0].url).toBe('http://example.com');
});
it('decodeMessage should delegate to decoder', () => {
const msg = { tp: MType.Timestamp, time: 0 } as any;
const decoder = (manager as any).decoder;
manager.decodeMessage(msg);
expect(decoder.decode).toHaveBeenCalledWith(msg);
});
it('sortDomRemoveMessages comparator should prioritize head nodes', () => {
const mock = { sortPages: jest.fn() };
// @ts-ignore private access
(manager as any).pagesManager = mock;
const msgs = [
{ id: 1, parentID: 1, tp: MType.RemoveNode, time: 10 },
{ id: 2, parentID: 2, tp: MType.RemoveNode, time: 10 },
{ id: 3, parentID: 2, tp: MType.CreateElementNode, time: 10 },
] as any[];
manager.sortDomRemoveMessages(msgs);
const comparator = mock.sortPages.mock.calls[0][0];
expect(comparator(msgs[0], msgs[2])).toBe(-1);
expect(comparator(msgs[2], msgs[0])).toBe(1);
expect(comparator(msgs[0], msgs[1])).toBe(-1);
expect(comparator(msgs[1], msgs[0])).toBe(1);
});

View file

@ -0,0 +1,144 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
class MockIOSPlayer {
static INITIAL_STATE = { ios: true };
public toggleRange = jest.fn();
constructor(
public store: any,
public session: any,
public handler?: any,
) {}
}
class MockWebPlayer {
static INITIAL_STATE = { web: true };
public toggleRange = jest.fn();
constructor(
public store: any,
public session: any,
public live: boolean,
public clickMap: boolean,
public handler?: any,
public prefetched?: boolean,
) {}
}
class MockWebLivePlayer {
static INITIAL_STATE = { live: true };
constructor(
public store: any,
public session: any,
public config: any,
public agentId: number,
public projectId: number,
public handler?: any,
) {}
}
jest.mock('../app/player/mobile/IOSPlayer', () => ({
__esModule: true,
default: MockIOSPlayer,
}));
jest.mock('../app/player/web/WebPlayer', () => ({
__esModule: true,
default: MockWebPlayer,
}));
jest.mock('../app/player/web/WebLivePlayer', () => ({
__esModule: true,
default: MockWebLivePlayer,
}));
import {
createIOSPlayer,
createWebPlayer,
createClickMapPlayer,
createLiveWebPlayer,
createClipPlayer,
} from '../app/player/create';
const session = { id: 1 } as any;
const errorHandler = { error: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
});
describe('player factory functions', () => {
it('createIOSPlayer returns player and store', () => {
const [player, store] = createIOSPlayer(session, undefined, errorHandler);
expect(player).toBeInstanceOf(MockIOSPlayer);
expect(store.get()).toEqual(MockIOSPlayer.INITIAL_STATE);
expect((player as any).store).toBe(store);
expect((player as any).session).toBe(session);
expect((player as any).handler).toBe(errorHandler);
});
it('createIOSPlayer applies wrapStore', () => {
const wrapper = jest.fn((s) => s);
const [, store] = createIOSPlayer(session, wrapper);
expect(wrapper).toHaveBeenCalledWith(store);
});
it('createWebPlayer passes arguments correctly', () => {
const [player, store] = createWebPlayer(
session,
undefined,
errorHandler,
true,
);
expect(player).toBeInstanceOf(MockWebPlayer);
expect(store.get()).toEqual(MockWebPlayer.INITIAL_STATE);
expect((player as any).live).toBe(false);
expect((player as any).clickMap).toBe(false);
expect((player as any).prefetched).toBe(true);
});
it('createClickMapPlayer creates click map WebPlayer', () => {
const [player] = createClickMapPlayer(session);
expect(player).toBeInstanceOf(MockWebPlayer);
expect((player as any).clickMap).toBe(true);
});
it('createLiveWebPlayer passes all params', () => {
const cfg = [{ url: 'stun:test' }] as any;
const [player, store] = createLiveWebPlayer(
session,
cfg,
5,
7,
undefined,
errorHandler,
);
expect(player).toBeInstanceOf(MockWebLivePlayer);
expect(store.get()).toEqual(MockWebLivePlayer.INITIAL_STATE);
expect((player as any).config).toBe(cfg);
expect((player as any).agentId).toBe(5);
expect((player as any).projectId).toBe(7);
});
it('createClipPlayer mobile uses IOSPlayer and toggles range', () => {
const range: [number, number] = [1, 5];
const [player] = createClipPlayer(
session,
undefined,
undefined,
range,
true,
);
expect(player).toBeInstanceOf(MockIOSPlayer);
expect((player as any).toggleRange).toHaveBeenCalledWith(1, 5);
});
it('createClipPlayer web uses WebPlayer when not mobile', () => {
const range: [number, number] = [1, 5];
const [player] = createClipPlayer(
session,
undefined,
undefined,
range,
false,
);
expect(player).toBeInstanceOf(MockWebPlayer);
expect((player as any).toggleRange).toHaveBeenCalledWith(1, 5);
});
});

View file

@ -0,0 +1,26 @@
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
value: jest.fn(() => ({
canvas: {
width: 800,
height: 600,
},
fillRect: jest.fn(),
clearRect: jest.fn(),
getImageData: jest.fn(() => ({ data: [] })),
putImageData: jest.fn(),
createImageData: jest.fn(() => []),
setTransform: jest.fn(),
drawImage: jest.fn(),
save: jest.fn(),
fillText: jest.fn(),
restore: jest.fn(),
beginPath: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
closePath: jest.fn(),
stroke: jest.fn(),
strokeRect: jest.fn(),
arc: jest.fn(),
fill: jest.fn(),
})),
});

View file

@ -1,3 +1,5 @@
import { ISession, } from '@/types/session/session';
export const issues = [
{
issueId: '9158adad14bcb1e0db18384c778a18f5ef2',
@ -84,7 +86,8 @@ export const events = [
},
{
time: 13146,
label: 'Search sessions using any captured event (click, input, page, error...)',
label:
'Search sessions using any captured event (click, input, page, error...)',
key: 3,
target: { path: undefined, label: undefined },
type: 'CLICK',
@ -145,7 +148,8 @@ export const events = [
},
{
time: 15166,
label: 'Search sessions using any captured event (click, input, page, error...)',
label:
'Search sessions using any captured event (click, input, page, error...)',
key: 7,
target: { path: undefined, label: undefined },
type: 'CLICK',
@ -155,7 +159,8 @@ export const events = [
},
{
time: 15726,
label: 'Search sessions using any captured event (click, input, page, error...)',
label:
'Search sessions using any captured event (click, input, page, error...)',
key: 8,
target: { path: undefined, label: undefined },
type: 'INPUT',
@ -173,3 +178,32 @@ export const events = [
count: undefined,
},
];
export const mockSession: (props: { duration?: number, projectId?: string, sessionId?: string, favorite?: boolean }) => ISession = ({
duration = 2122, projectId = '5095', sessionId = '3315944703327552482', favorite = false,
}) => ({
duration,
errorsCount: 0,
eventsCount: 0,
issueScore: 1747645706,
issueTypes: [],
metadata: {userId: null, demo: null},
pagesCount: 0,
platform: 'web',
projectId,
sessionId,
startTs: 1747645706000,
timezone: 'UTC+08:00',
userAnonymousId: 'null',
userBrowser: 'Chrome',
userCity: 'Singapore',
userCountry: 'SG',
userDevice: 'null',
userDeviceType: 'desktop',
userId: '',
userOs: 'Mac OS X',
userState: '',
userUuid: 'b8268c1d-9c5d-4b47-9893-5c0a040adc8f',
viewed: false,
favorite,
});

View file

@ -0,0 +1,4 @@
module.exports = {
__esModule: true,
default: 'SvgMock',
};

View file

@ -0,0 +1,139 @@
import { describe, expect, it, beforeEach, jest } from '@jest/globals';
jest.mock('App/mstore/types/filterItem', () => {
return class FilterItem {
key: any;
value: any;
operator: any;
source: any;
sourceOperator: any;
isEvent: any;
filters: any;
constructor(data: any = {}) {
Object.assign(this, data);
}
fromJson(data: any) {
Object.assign(this, data);
return this;
}
merge(data: any) {
Object.assign(this, data);
}
toJson() {
return {
type: this.key || this.type,
value: this.value,
operator: this.operator,
source: this.source,
sourceOperator: this.sourceOperator,
isEvent: this.isEvent,
filters: Array.isArray(this.filters) ? this.filters : [],
};
}
};
});
jest.mock('Types/filter/newFilter', () => {
const { FilterKey, FilterCategory } = require('Types/filter/filterType');
return {
filtersMap: {
[FilterKey.USERID]: { key: FilterKey.USERID, type: FilterKey.USERID, category: FilterCategory.USER, operator: 'is', value: [''] },
[FilterKey.DURATION]: { key: FilterKey.DURATION, type: FilterKey.DURATION, category: FilterCategory.SESSION, operator: 'is', value: [0, 0] },
[FilterKey.ISSUE]: { key: FilterKey.ISSUE, type: FilterKey.ISSUE, category: FilterCategory.ISSUE, operator: 'is', value: [] },
},
conditionalFiltersMap: {},
generateFilterOptions: jest.fn(() => []),
liveFiltersMap: {},
mobileConditionalFiltersMap: {},
};
});
const mockSessionFetch = jest.fn().mockResolvedValue({});
const mockSessionStore = {
fetchSessions: mockSessionFetch,
total: 0,
clearList: jest.fn(),
};
const mockSettingsStore = {
sessionSettings: { durationFilter: { count: 0 } },
};
jest.mock('App/services', () => ({
searchService: { fetchSavedSearch: jest.fn() },
sessionService: { getSessions: jest.fn().mockResolvedValue({ sessions: [], total: 0 }) },
}));
jest.mock('App/mstore', () => ({
sessionStore: mockSessionStore,
settingsStore: mockSettingsStore,
}));
import SearchStore, { checkValues, filterMap } from '../app/mstore/searchStore';
import SavedSearch from '../app/mstore/types/savedSearch';
import { FilterCategory, FilterKey } from '../app/types/filter/filterType';
describe('searchStore utilities', () => {
it('checkValues handles duration', () => {
const res = checkValues(FilterKey.DURATION, ['', 1000]);
expect(res).toEqual([0, 1000]);
});
it('checkValues filters empty values', () => {
const res = checkValues(FilterKey.USERID, ['a', '', null]);
expect(res).toEqual(['a']);
});
it('filterMap maps metadata type correctly', () => {
const data = {
category: FilterCategory.METADATA,
value: ['val'],
key: '_source',
operator: 'is',
sourceOperator: 'is',
source: '_source',
custom: false,
isEvent: false,
filters: null,
};
const mapped = filterMap(data as any);
expect(mapped.type).toBe(FilterKey.METADATA);
expect(mapped.source).toBe('source');
expect(mapped.value).toEqual(['val']);
});
});
describe('SearchStore class', () => {
let store: SearchStore;
beforeEach(() => {
store = new SearchStore();
mockSessionFetch.mockClear();
});
it('applySavedSearch sets filters', () => {
const saved = new SavedSearch({
name: 'test',
filter: { filters: [{ key: FilterKey.USERID, value: ['123'], operator: 'is' }] },
});
store.applySavedSearch(saved);
expect(store.savedSearch).toBe(saved);
expect(store.instance.filters.length).toBe(1);
// @ts-ignore
expect(store.instance.filters[0].key).toBe(FilterKey.USERID);
});
it('addFilterByKeyAndValue adds filter and triggers fetch', () => {
store.addFilterByKeyAndValue(FilterKey.USERID, ['42']);
expect(store.instance.filters.length).toBe(1);
expect(mockSessionFetch).toHaveBeenCalled();
});
it('fetchSessions applies duration filter from settings', async () => {
mockSettingsStore.sessionSettings.durationFilter = { operator: '<', count: 1, countType: 'sec' };
await store.fetchSessions();
const call = mockSessionFetch.mock.calls[0][0];
const duration = call.filters.find((f: any) => f.type === FilterKey.DURATION);
expect(duration).toBeTruthy();
expect(duration.value).toEqual([1000, 0]);
});
});

View file

@ -0,0 +1,355 @@
import { sessionService } from '../app/services';
import Session from '../app/types/session'
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import SessionStore from '../app/mstore/sessionStore';
import { searchStore } from '../app/mstore/index';
import { checkEventWithFilters } from '../app/components/Session_/Player/Controls/checkEventWithFilters';
import { mockSession } from './mocks/sessionData';
jest.mock('../app/player', () => ({
createWebPlayer: jest.fn(),
createIOSPlayer: jest.fn(),
createClickMapPlayer: jest.fn(),
createLiveWebPlayer: jest.fn(),
createClipPlayer: jest.fn()
}));
jest.mock('../app/services', () => ({
sessionService: {
getSessions: jest.fn(),
getLiveSessions: jest.fn(),
getSessionInfo: jest.fn(),
getSessionEvents: jest.fn(),
getSessionNotes: jest.fn(),
getFavoriteSessions: jest.fn(),
getSessionClickMap: jest.fn(),
toggleFavorite: jest.fn(),
getClickMap: jest.fn(),
getAutoplayList: jest.fn(),
getFirstMobUrl: jest.fn(),
},
}));
jest.mock('App/player/web/network/loadFiles', () => ({
loadFile: jest.fn(),
}));
jest.mock(
'@/components/Session_/Player/Controls/checkEventWithFilters',
() => ({
checkEventWithFilters: jest.fn(),
}),
);
jest.mock('../app/mstore/index', () => ({
searchStore: {
instance: {
filters: [],
events: [],
toSearch: jest.fn().mockReturnValue({}),
},
},
searchStoreLive: {
instance: {
filters: [],
events: [],
},
},
}));
describe('SessionStore', () => {
let sessionStore: SessionStore;
beforeEach(() => {
jest.clearAllMocks();
sessionStore = new SessionStore();
});
describe('resetUserFilter', () => {
it('should reset user filter', () => {
sessionStore.userFilter.update('page', 5);
expect(sessionStore.userFilter.page).toBe(5);
sessionStore.resetUserFilter();
expect(sessionStore.userFilter.page).toBe(1);
});
});
describe('fetchLiveSessions', () => {
it('should fetch and set live sessions', async () => {
const mockResponse = {
sessions: [{ sessionId: 'live-1', userId: 'user1' }],
total: 1,
};
(sessionService.getLiveSessions as jest.Mock).mockResolvedValue(
mockResponse,
);
await sessionStore.fetchLiveSessions({ sort: 'timestamp' });
expect(sessionService.getLiveSessions).toHaveBeenCalledWith({
sort: 'timestamp',
});
expect(sessionStore.liveSessions.length).toBe(1);
expect(sessionStore.liveSessions[0].sessionId).toBe('live-1');
expect(sessionStore.liveSessions[0].live).toBe(true);
expect(sessionStore.totalLiveSessions).toBe(1);
expect(sessionStore.loadingLiveSessions).toBe(false);
});
it('should handle duration sort by converting to timestamp', async () => {
(sessionService.getLiveSessions as jest.Mock).mockResolvedValue({
sessions: [],
total: 0,
});
await sessionStore.fetchLiveSessions({ sort: 'duration', order: 'asc' });
expect(sessionService.getLiveSessions).toHaveBeenCalledWith({
sort: 'timestamp',
order: 'desc',
});
});
it('should handle errors and set loading to false', async () => {
const mockError = new Error('API error');
console.error = jest.fn();
(sessionService.getLiveSessions as jest.Mock).mockRejectedValue(
mockError,
);
await sessionStore.fetchLiveSessions();
expect(console.error).toHaveBeenCalledWith(mockError);
expect(sessionStore.loadingLiveSessions).toBe(false);
});
});
describe('fetchSessions', () => {
it('should fetch and set sessions', async () => {
const mockResponse = {
sessions: [
new Session(mockSession({ sessionId: '1', favorite: true })),
new Session(mockSession({ sessionId: '2' })),
],
total: 2,
};
(sessionService.getSessions as jest.Mock).mockResolvedValue(mockResponse);
await sessionStore.fetchSessions({ page: 1, filters: [] }, true);
expect(sessionService.getSessions).toHaveBeenCalledWith({
page: 1,
filters: [],
});
expect(sessionStore.list.length).toBe(2);
expect(sessionStore.total).toBe(2);
expect(sessionStore.sessionIds).toEqual(['1', '2']);
expect(sessionStore.favoriteList.length).toBe(1);
expect(sessionStore.favoriteList[0].sessionId).toBe('1');
expect(sessionStore.loadingSessions).toBe(false);
});
it('should handle errors and set loading to false', async () => {
const mockError = new Error('API error');
console.error = jest.fn();
(sessionService.getSessions as jest.Mock).mockRejectedValue(mockError);
await sessionStore.fetchSessions({ filters: [] }, true);
expect(console.error).toHaveBeenCalledWith(mockError);
expect(sessionStore.loadingSessions).toBe(false);
});
});
describe('clearAll', () => {
it('should clear session list and current session', () => {
sessionStore.list = [new Session({ sessionId: 'test' })];
sessionStore.current = new Session({ sessionId: 'current' });
sessionStore.clearAll();
expect(sessionStore.list).toEqual([]);
expect(sessionStore.current.sessionId).toBe('');
});
});
describe('fetchSessionData', () => {
it('should fetch and set session data with events', async () => {
const mockSessionId = 'test-session-id';
const mockSessionInfo = { sessionId: mockSessionId, userId: 'user1' };
const mockEventsData = {
events: [
{ type: 'LOCATION', url: 'https://example.com', time: 100 },
{ type: 'CLICK', value: 'button', time: 200 },
],
errors: [],
crashes: [],
issues: [],
resources: [],
stackEvents: [],
userEvents: [],
userTesting: [],
};
(sessionService.getSessionInfo as jest.Mock).mockResolvedValue(
mockSessionInfo,
);
(sessionService.getSessionEvents as jest.Mock).mockResolvedValue(
mockEventsData,
);
(checkEventWithFilters as jest.Mock).mockReturnValue(false);
await sessionStore.fetchSessionData(mockSessionId);
expect(sessionService.getSessionInfo).toHaveBeenCalledWith(
mockSessionId,
false,
);
expect(sessionService.getSessionEvents).toHaveBeenCalledWith(
mockSessionId,
);
expect(sessionStore.current.sessionId).toBe(mockSessionId);
expect(sessionStore.visitedEvents.length).toBe(1);
expect(sessionStore.visitedEvents[0].url).toBe('https://example.com');
expect(sessionStore.host).toBe('');
expect(sessionStore.prefetched).toBe(false);
});
it('should handle event filtering based on search filters', async () => {
const mockSessionId = 'test-session-id';
const mockSessionInfo = { sessionId: mockSessionId, userId: 'user1' };
const mockEventsData = {
events: [
{ type: 'LOCATION', url: 'https://example.com', time: 100 },
{ type: 'CLICK', value: 'button', time: 200 },
],
};
// Setup search filters
(searchStore.instance.events as any) = [
{ key: 'LOCATION', operator: 'is', value: 'https://example.com' },
];
(sessionService.getSessionInfo as jest.Mock).mockResolvedValue(
mockSessionInfo,
);
(sessionService.getSessionEvents as jest.Mock).mockResolvedValue(
mockEventsData,
);
(checkEventWithFilters as jest.Mock).mockReturnValue(true);
await sessionStore.fetchSessionData(mockSessionId);
expect(sessionStore.eventsIndex).toEqual([0]);
});
it('should handle different filter operators', async () => {
const mockSessionId = 'test-session-id';
const mockSessionInfo = { sessionId: mockSessionId, userId: 'user1' };
const mockEventsData = {
events: [
{ type: 'LOCATION', url: 'https://example.com', time: 100 },
{ type: 'CLICK', value: 'test-button', time: 200 },
],
};
(searchStore.instance.events as any) = [
{ key: 'CLICK', operator: 'contains', value: 'test' },
];
(sessionService.getSessionInfo as jest.Mock).mockResolvedValue(
mockSessionInfo,
);
(sessionService.getSessionEvents as jest.Mock).mockResolvedValue(
mockEventsData,
);
(checkEventWithFilters as jest.Mock).mockReturnValue(true);
await sessionStore.fetchSessionData(mockSessionId);
expect(sessionStore.eventsIndex).toEqual([1]);
});
it('should handle errors when fetching events', async () => {
const mockSessionId = 'test-session-id';
const mockSessionInfo = { sessionId: mockSessionId, userId: 'user1' };
const mockError = new Error('API error');
console.error = jest.fn();
(sessionService.getSessionInfo as jest.Mock).mockResolvedValue(
mockSessionInfo,
);
(sessionService.getSessionEvents as jest.Mock).mockRejectedValue(
mockError,
);
await sessionStore.fetchSessionData(mockSessionId);
expect(console.error).toHaveBeenCalledWith(
'Failed to fetch events',
mockError,
);
expect(sessionStore.current.sessionId).toBe(mockSessionId);
expect(sessionStore.current.events).toEqual([]);
});
it('should handle errors when fetching session info', async () => {
const mockSessionId = 'test-session-id';
const mockError = new Error('API error');
console.error = jest.fn();
(sessionService.getSessionInfo as jest.Mock).mockRejectedValue(mockError);
await sessionStore.fetchSessionData(mockSessionId);
expect(console.error).toHaveBeenCalledWith(mockError);
expect(sessionStore.fetchFailed).toBe(true);
});
});
describe('sortSessions', () => {
it('should sort sessions by the specified key in ascending order', () => {
sessionStore.list = [
new Session(mockSession({ duration: 3000, sessionId: '1' })),
new Session(mockSession({ duration: 1000, sessionId: '2' })),
new Session(mockSession({ duration: 2000, sessionId: '3' })),
];
sessionStore.favoriteList = [
new Session(mockSession({ duration: 3000, sessionId: '1' })),
new Session(mockSession({ duration: 2000, sessionId: '3' })),
];
sessionStore.sortSessions('duration', 1);
expect(sessionStore.list.map((s) => s.sessionId)).toEqual([
'2',
'3',
'1',
]);
expect(sessionStore.favoriteList.map((s) => s.sessionId)).toEqual([
'3',
'1',
]);
});
it('should sort sessions by the specified key in descending order', () => {
sessionStore.list = [
new Session(mockSession({ duration: 3000, sessionId: '1' })),
new Session(mockSession({ duration: 1000, sessionId: '2' })),
new Session(mockSession({ duration: 2000, sessionId: '3' })),
];
sessionStore.sortSessions('duration', -1);
expect(sessionStore.list.map((s) => s.sessionId)).toEqual([
'1',
'3',
'2',
]);
});
});
});

View file

@ -16,6 +16,7 @@
"moduleResolution": "node",
"noEmit": true,
"jsx": "react",
"baseUrl": ".",
"paths": {
"@/*": ["./app/*"],
"App/*": ["./app/*"],
@ -27,7 +28,7 @@
"Shared/*": ["./app/components/shared/*"],
"Player/*": ["./app/player/*"],
"Player": ["./app/player"],
"HOCs/*": ["./app/components/hocs/*"],
"HOCs/*": ["./app/components/hocs/*"]
}
},
"include": [
@ -36,6 +37,8 @@
"app/**/*.tsx",
"app/**/*.js",
"app/**/*.jsx",
"window.d.ts"
"window.d.ts",
"cypress/snapshots/sessionStore.test.ts",
"tests/create.test.ts"
]
}

View file

@ -3571,6 +3571,16 @@ __metadata:
languageName: node
linkType: hard
"@types/jest@npm:^29.5.14":
version: 29.5.14
resolution: "@types/jest@npm:29.5.14"
dependencies:
expect: "npm:^29.0.0"
pretty-format: "npm:^29.0.0"
checksum: 10c1/645f1559cff204d5942980ecceca5d1eaaa2c6cb6c784bc60525bc195b5e7dd7342496740130d845b4227fa51c83ef2638a479eb06464b725c688d7254874133
languageName: node
linkType: hard
"@types/jsdom@npm:^20.0.0":
version: 20.0.1
resolution: "@types/jsdom@npm:20.0.1"
@ -8243,7 +8253,7 @@ __metadata:
languageName: node
linkType: hard
"expect@npm:^29.7.0":
"expect@npm:^29.0.0, expect@npm:^29.7.0":
version: 29.7.0
resolution: "expect@npm:29.7.0"
dependencies:
@ -10900,7 +10910,7 @@ __metadata:
languageName: node
linkType: hard
"jest@npm:^29.5.0":
"jest@npm:^29.7.0":
version: 29.7.0
resolution: "jest@npm:29.7.0"
dependencies:
@ -13071,6 +13081,7 @@ __metadata:
"@tanstack/react-query": "npm:^5.76.0"
"@trivago/prettier-plugin-sort-imports": "npm:^4.3.0"
"@types/eslint-plugin-jsx-a11y": "npm:^6"
"@types/jest": "npm:^29.5.14"
"@types/luxon": "npm:^3.4.2"
"@types/node": "npm:^22.7.8"
"@types/prismjs": "npm:^1"
@ -13123,7 +13134,7 @@ __metadata:
i18next: "npm:^24.2.2"
i18next-browser-languagedetector: "npm:^8.1.0"
immutable: "npm:^4.3.7"
jest: "npm:^29.5.0"
jest: "npm:^29.7.0"
jest-environment-jsdom: "npm:^29.5.0"
js-untar: "npm:^2.0.0"
jspdf: "npm:^3.0.1"
@ -13173,7 +13184,7 @@ __metadata:
tailwindcss: "npm:^3.4.17"
thread-loader: "npm:^4.0.4"
ts-api-utils: "npm:^2.1.0"
ts-jest: "npm:^29.0.5"
ts-jest: "npm:^29.3.3"
ts-node: "npm:^10.7.0"
typescript: "npm:^4.9.5"
typescript-eslint: "npm:^8.32.1"
@ -14204,7 +14215,7 @@ __metadata:
languageName: node
linkType: hard
"pretty-format@npm:^29.7.0":
"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0":
version: 29.7.0
resolution: "pretty-format@npm:29.7.0"
dependencies:
@ -17464,7 +17475,7 @@ __metadata:
languageName: node
linkType: hard
"ts-jest@npm:^29.0.5":
"ts-jest@npm:^29.3.3":
version: 29.3.3
resolution: "ts-jest@npm:29.3.3"
dependencies: