addunit tests for session events parser (#3423)

* addunit tests for session events parser

* fixed tests and add test check to deploy

* updated frontend workflow

* updated frontend workflow

* updated frontend workflow

* updated frontend workflow

* updated frontend workflow

* updated frontend workflow

* fix test
This commit is contained in:
Andrey Babushkin 2025-05-22 15:18:03 +02:00 committed by GitHub
parent dedeb4cb2c
commit d3d1a40909
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1358 additions and 124 deletions

33
.github/workflows/frontend-tests.yaml vendored Normal file
View file

@ -0,0 +1,33 @@
name: Frontend tests
on:
pull_request:
paths:
- 'frontend/**'
- '.github/workflows/frontend-test.yaml'
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install dependencies
working-directory: frontend
run: yarn
- name: Run tests
working-directory: frontend
run: yarn test:ci
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
directory: frontend/coverage/

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,205 @@
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);
});
});

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

@ -1,6 +1,6 @@
import { describe, expect, jest, test } from '@jest/globals';
import FeatureFlagsStore from 'App/mstore/FeatureFlagsStore';
import FeatureFlagsStore from 'App/mstore/featureFlagsStore';
import FeatureFlag from 'App/mstore/types/FeatureFlag';
const mockFlags = [{ featureFlagId: 1 }, { featureFlagId: 2 }];

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 = 34490, 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: 1747829271755,
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

@ -7,8 +7,11 @@ import {
getResourceFromResourceTiming,
getResourceFromNetworkRequest,
} from '../app/player/web/types/resource';
import type { ResourceTiming, NetworkRequest } from '../app/player/web/messages';
import { test, describe, expect } from "@jest/globals";
import type {
ResourceTiming,
NetworkRequest,
} from '../app/player/web/messages';
import { test, describe, expect } from '@jest/globals';
describe('getURLExtention', () => {
test('should return the correct extension', () => {
@ -21,20 +24,36 @@ describe('getURLExtention', () => {
describe('getResourceType', () => {
test('should return the correct resource type based on initiator and URL', () => {
expect(getResourceType('fetch', 'https://test.com')).toBe(ResourceType.FETCH);
expect(getResourceType('beacon', 'https://test.com')).toBe(ResourceType.BEACON);
expect(getResourceType('fetch', 'https://test.com')).toBe(
ResourceType.FETCH,
);
expect(getResourceType('beacon', 'https://test.com')).toBe(
ResourceType.BEACON,
);
expect(getResourceType('img', 'https://test.com')).toBe(ResourceType.IMG);
expect(getResourceType('unknown', 'https://test.com/script.js')).toBe(ResourceType.SCRIPT);
expect(getResourceType('unknown', 'https://test.com/style.css')).toBe(ResourceType.CSS);
expect(getResourceType('unknown', 'https://test.com/image.png')).toBe(ResourceType.IMG);
expect(getResourceType('unknown', 'https://test.com/video.mp4')).toBe(ResourceType.MEDIA);
expect(getResourceType('unknown', 'https://test.com')).toBe(ResourceType.OTHER);
expect(getResourceType('unknown', 'https://test.com/script.js')).toBe(
ResourceType.SCRIPT,
);
expect(getResourceType('unknown', 'https://test.com/style.css')).toBe(
ResourceType.CSS,
);
expect(getResourceType('unknown', 'https://test.com/image.png')).toBe(
ResourceType.IMG,
);
expect(getResourceType('unknown', 'https://test.com/video.mp4')).toBe(
ResourceType.MEDIA,
);
expect(getResourceType('unknown', 'https://test.com')).toBe(
ResourceType.OTHER,
);
});
});
describe('getResourceName', () => {
test('should return the last non-empty section of a URL', () => {
expect(getResourceName('https://test.com/path/to/resource')).toBe('resource');
expect(getResourceName('https://test.com/path/to/resource')).toBe(
'resource',
);
expect(getResourceName('https://test.com/another/path/')).toBe('path');
expect(getResourceName('https://test.com/singlepath')).toBe('singlepath');
expect(getResourceName('https://test.com/')).toBe('test.com');
@ -56,6 +75,7 @@ describe('Resource', () => {
...testResource,
name: 'script.js',
isYellow: false,
isRed: false,
};
expect(Resource(testResource)).toEqual(expectedResult);
});
@ -75,7 +95,7 @@ describe('getResourceFromResourceTiming', () => {
initiator: 'fetch',
transferredSize: 500,
cached: false,
time: 123
time: 123,
};
const expectedResult = Resource({
...testResourceTiming,
@ -84,8 +104,20 @@ describe('getResourceFromResourceTiming', () => {
success: true,
status: '2xx-3xx',
time: 123,
timings: {
contentDownload: undefined,
dnsLookup: undefined,
initialConnection: undefined,
queueing: undefined,
ssl: undefined,
stalled: undefined,
total: undefined,
ttfb: 100,
},
});
expect(getResourceFromResourceTiming(testResourceTiming, 0)).toEqual(expectedResult);
expect(getResourceFromResourceTiming(testResourceTiming, 0)).toEqual(
expectedResult,
);
});
});
@ -102,16 +134,19 @@ describe('getResourceFromNetworkRequest', () => {
timestamp: 123,
duration: 1,
transferredBodySize: 100,
time: 123
time: 123,
} as const;
// @ts-ignore
const expectedResult = Resource({
...testNetworkRequest,
success: true,
status: '200',
timings: {},
time: 123,
decodedBodySize: 100,
});
expect(getResourceFromNetworkRequest(testNetworkRequest, 0)).toEqual(expectedResult);
expect(getResourceFromNetworkRequest(testNetworkRequest, 0)).toEqual(
expectedResult,
);
});
});

View file

@ -12,7 +12,7 @@ const strings = [
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;700;900&display=swap');
#login-required {
color: #fff;
}`,
};`,
`@import url("style.css") screen and (max-width: 600px);`
];
const testStrings = [

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

@ -23,11 +23,11 @@ __metadata:
linkType: hard
"@ant-design/colors@npm:^7.0.0, @ant-design/colors@npm:^7.2.0":
version: 7.2.0
resolution: "@ant-design/colors@npm:7.2.0"
version: 7.2.1
resolution: "@ant-design/colors@npm:7.2.1"
dependencies:
"@ant-design/fast-color": "npm:^2.0.6"
checksum: 10c1/cf9eec1bf6ccc6f6757194dccdcc11f2dd84e14e8be2d3db6f85bca20e05432340a3df55632eed1d880bc8691efc1869fa0f18cb1f494aafb85b1565c71c2609
checksum: 10c1/b8f3c98a55877da647fe4158e88600500713ba00d90ecd6a98c6cff068bd556771c833e65853d9e3443298ef114dd262f81b518c8b9fe364b80500c475c72788
languageName: node
linkType: hard
@ -3038,61 +3038,61 @@ __metadata:
languageName: node
linkType: hard
"@sentry-internal/browser-utils@npm:9.21.0":
version: 9.21.0
resolution: "@sentry-internal/browser-utils@npm:9.21.0"
"@sentry-internal/browser-utils@npm:9.22.0":
version: 9.22.0
resolution: "@sentry-internal/browser-utils@npm:9.22.0"
dependencies:
"@sentry/core": "npm:9.21.0"
checksum: 10c1/a19819666dbd22148321c9e177f42dfc49c3779ed134076d91f005d2e8c4f90b5bef0214c305ffbdcfeccdcc0caca4f548707a8505af81a2b6ec2ee570da796c
"@sentry/core": "npm:9.22.0"
checksum: 10c1/526a908c4597d8d081dcaf87072f4d55bd438ea9f12a46417b81ebd5c44751a23cfe35f3f16e1805f9e7efd9b27b1848f6cbe3f75f8ba8115c67b10f53cba2e8
languageName: node
linkType: hard
"@sentry-internal/feedback@npm:9.21.0":
version: 9.21.0
resolution: "@sentry-internal/feedback@npm:9.21.0"
"@sentry-internal/feedback@npm:9.22.0":
version: 9.22.0
resolution: "@sentry-internal/feedback@npm:9.22.0"
dependencies:
"@sentry/core": "npm:9.21.0"
checksum: 10c1/d12bfbb61abbcfddb67d42c7aaa1545e34f4df1210eb8dec3347eba7133e108b6b9cda680d5c131e7534882bb03c8c36972e9ab1396ca90d2113dd991d67138d
"@sentry/core": "npm:9.22.0"
checksum: 10c1/31e1d61691022678df190f69a9c81cbf1e6b9c3e8218681dc4e30e2dc75e1ec28ca1fda0bd4e13f92af473a68c797f03af3440bcefa16aff44cca5b8224373d6
languageName: node
linkType: hard
"@sentry-internal/replay-canvas@npm:9.21.0":
version: 9.21.0
resolution: "@sentry-internal/replay-canvas@npm:9.21.0"
"@sentry-internal/replay-canvas@npm:9.22.0":
version: 9.22.0
resolution: "@sentry-internal/replay-canvas@npm:9.22.0"
dependencies:
"@sentry-internal/replay": "npm:9.21.0"
"@sentry/core": "npm:9.21.0"
checksum: 10c1/24ab33e4edf8aa7c9871eddd2492b080ad0ce40e0a04b8a11f99d4a034d8e34a632e155f380c24ea49104e666a12c26d544c623551a9ee21ef602023bac5c919
"@sentry-internal/replay": "npm:9.22.0"
"@sentry/core": "npm:9.22.0"
checksum: 10c1/3108fe76bf0dd458b2e0ff748b42ccb59a28756720b2e55c1c1e44111bf93e4d4297a09b2cc08e6039abac5692bd2c33d79e7d859b31d142bd0128ee32738a30
languageName: node
linkType: hard
"@sentry-internal/replay@npm:9.21.0":
version: 9.21.0
resolution: "@sentry-internal/replay@npm:9.21.0"
"@sentry-internal/replay@npm:9.22.0":
version: 9.22.0
resolution: "@sentry-internal/replay@npm:9.22.0"
dependencies:
"@sentry-internal/browser-utils": "npm:9.21.0"
"@sentry/core": "npm:9.21.0"
checksum: 10c1/9f5e9296b7026902e77714563933e40483c4fdcf9a02586af726b503a922d3df89bbe9ab7224edf532894e76bd3fdc63b280e4d417d06c8ebd19ca102863d6b5
"@sentry-internal/browser-utils": "npm:9.22.0"
"@sentry/core": "npm:9.22.0"
checksum: 10c1/3e4b81005e6995437f842e05f0d13894e888ef4ef0b770f65e02d396985420192af0d69e613f4b9f96fb86d160bcd63b56874aba88022e28f394c8756c7cbda0
languageName: node
linkType: hard
"@sentry/browser@npm:^9.18.0":
version: 9.21.0
resolution: "@sentry/browser@npm:9.21.0"
version: 9.22.0
resolution: "@sentry/browser@npm:9.22.0"
dependencies:
"@sentry-internal/browser-utils": "npm:9.21.0"
"@sentry-internal/feedback": "npm:9.21.0"
"@sentry-internal/replay": "npm:9.21.0"
"@sentry-internal/replay-canvas": "npm:9.21.0"
"@sentry/core": "npm:9.21.0"
checksum: 10c1/81a50d67a0e7bda344bc1fbaa4dad67061d38e1a0901c87df4938890361a4614e4830a4b886b3fcc2d0f869b686e7c0e5bb33d1fb2d7543fedecc7e6107a0fce
"@sentry-internal/browser-utils": "npm:9.22.0"
"@sentry-internal/feedback": "npm:9.22.0"
"@sentry-internal/replay": "npm:9.22.0"
"@sentry-internal/replay-canvas": "npm:9.22.0"
"@sentry/core": "npm:9.22.0"
checksum: 10c1/94e4b79cf04d6b4ee935d4200e26094b2e20f119ba54f2234ec78b5aea9499eefef8c82260846843c95fc8ecdff79435fcb7e1c6a054c157273304e545bbe94c
languageName: node
linkType: hard
"@sentry/core@npm:9.21.0":
version: 9.21.0
resolution: "@sentry/core@npm:9.21.0"
checksum: 10c1/5bca1342cb4f113f524b0f31a3a0c8c1db448949ed79a517e0dc4a6c66516138034372bde78b6ab27a4694336773df3cc87ad96fdd4f6787dd9eb06b32dc6143
"@sentry/core@npm:9.22.0":
version: 9.22.0
resolution: "@sentry/core@npm:9.22.0"
checksum: 10c1/6203d394b62110219b6b751698e89d08ce6ab52dd90e1df74778a5cb966558334d4273aff4d4249249b76530856d5ae0c8ec2018193f096462e6d9ce753d4933
languageName: node
linkType: hard
@ -3553,6 +3553,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"
@ -3618,11 +3628,11 @@ __metadata:
linkType: hard
"@types/node@npm:*, @types/node@npm:^22.7.8":
version: 22.15.19
resolution: "@types/node@npm:22.15.19"
version: 22.15.21
resolution: "@types/node@npm:22.15.21"
dependencies:
undici-types: "npm:~6.21.0"
checksum: 10c1/b57519cdfa277e62bfe38397f764239c24767740a7bf9999efb3972e626e1c5d9c305c89d8455ca20e34ce719746d6e1316de883af1b7a913fccb7ee60741e1a
checksum: 10c1/dade6e3665167bd82d4ee2374f98eff78777c0ffa069b4c1146f3cbdacd7f412e9f1cb24e7d978b40fd832956121ba86acba0612cddbfe093bf660682f84eaeb
languageName: node
linkType: hard
@ -3710,11 +3720,11 @@ __metadata:
linkType: hard
"@types/react@npm:*, @types/react@npm:^19.0.10":
version: 19.1.4
resolution: "@types/react@npm:19.1.4"
version: 19.1.5
resolution: "@types/react@npm:19.1.5"
dependencies:
csstype: "npm:^3.0.2"
checksum: 10c1/725f4d9dbee82273ee358c2a5fbc634c847c7167118715ba5bb44e8f37c4c288fbfd3ada0b77b46af1792c33d97c97b006f5b557e9a1690631901cf3474f5aa7
checksum: 10c1/3eaa6c8cad7ec0fce16a01c1c83cf44931ee0c09d4f5c9c20c650ecb42405bd8a800649342f3721a1f2cbd14393718e9fb72423f26fec5b01bc9a62688668846
languageName: node
linkType: hard
@ -8147,7 +8157,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:
@ -8822,11 +8832,11 @@ __metadata:
linkType: hard
"get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.7.0":
version: 4.10.0
resolution: "get-tsconfig@npm:4.10.0"
version: 4.10.1
resolution: "get-tsconfig@npm:4.10.1"
dependencies:
resolve-pkg-maps: "npm:^1.0.0"
checksum: 10c1/9c77b946c79ede0940c3257ec02067b49faee25d7fb1f1376e3b7fab3b72250a33a4fdf9f970f79ceebedf04c92390f300d2647c91c2ce20695f4ed98ed90736
checksum: 10c1/ef21fd27464678c41e7e6516103097e1fd327a8cb3f21b07cd20cdeb4a134030fd00b30d6bbac7ff8268b73f367a87e1e702faa5827442041c329ab959cd0c61
languageName: node
linkType: hard
@ -10732,7 +10742,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:
@ -12880,6 +12890,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"
@ -12932,7 +12943,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"
@ -12982,7 +12993,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"
@ -13999,7 +14010,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:
@ -17202,7 +17213,7 @@ __metadata:
languageName: node
linkType: hard
"ts-jest@npm:^29.0.5":
"ts-jest@npm:^29.3.3":
version: 29.3.4
resolution: "ts-jest@npm:29.3.4"
dependencies:
@ -17972,12 +17983,12 @@ __metadata:
linkType: hard
"watchpack@npm:^2.4.1":
version: 2.4.3
resolution: "watchpack@npm:2.4.3"
version: 2.4.4
resolution: "watchpack@npm:2.4.4"
dependencies:
glob-to-regexp: "npm:^0.4.1"
graceful-fs: "npm:^4.1.2"
checksum: 10c1/dadcdad5b7ef9aa7f94bde18560dd5fd1c81261ca07b444ae475c643931d2a838050a71e03705aa88cfb67097aa9ca61ac11b82c13085af66aeb5b1fa4340215
checksum: 10c1/64e6d92b1d4728dd2ef20d56b26415a9565c47f3902462b171f85f3e996b1f3afe208b8f54fae414458841397655f9f22d474d7a6b2a998bfeda565e957f3b90
languageName: node
linkType: hard