diff --git a/.github/workflows/frontend-tests.yaml b/.github/workflows/frontend-tests.yaml new file mode 100644 index 000000000..8dd52268d --- /dev/null +++ b/.github/workflows/frontend-tests.yaml @@ -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/ + diff --git a/frontend/app/player/web/messages/PrimitiveReader.ts b/frontend/app/player/web/messages/PrimitiveReader.ts index 99c01fa7d..8d178a140 100644 --- a/frontend/app/player/web/messages/PrimitiveReader.ts +++ b/frontend/app/player/web/messages/PrimitiveReader.ts @@ -1,3 +1,5 @@ +import { TextDecoder } from 'util'; + export default class PrimitiveReader { /** pointer for curent position in the buffer */ protected p: number = 0; diff --git a/frontend/app/types/app/period.js b/frontend/app/types/app/period.js index 672d47251..1236bb751 100644 --- a/frontend/app/types/app/period.js +++ b/frontend/app/types/app/period.js @@ -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'; diff --git a/frontend/app/types/session/session.ts b/frontend/app/types/session/session.ts index 55170ced8..8e578b99c 100644 --- a/frontend/app/types/session/session.ts +++ b/frontend/app/types/session/session.ts @@ -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; - fileKey: string; + agentToken?: string; + notes?: Note[]; + notesWithEvents?: Array; + 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; diff --git a/frontend/jest.config.mjs b/frontend/jest.config.mjs index c0a1bdb72..f3931929c 100644 --- a/frontend/jest.config.mjs +++ b/frontend/jest.config.mjs @@ -5,6 +5,14 @@ export default { '^Types/(.+)$': '/app/types/$1', '^App/(.+)$': '/app/$1', "\\.(css|less)$": "/tests/mocks/style.mock.js", + '^@/(.*)$': '/app/$1', + '^Player/(.+)$': '/app/player/$1', + '^Player$': '/app/player', + '^UI/(.+)$': '/app/components/ui/$1', + '^UI$': '/app/components/ui', + '^Shared/(.+)$': '/app/components/shared/$1', + '\\.svg$': '/tests/mocks/svgMock.js', + '^Components/(.+)$': '/app/components/$1', }, collectCoverage: true, verbose: true, @@ -23,4 +31,5 @@ export default { transformIgnorePatterns: [ '/node_modules/(?!syncod)', ], + setupFiles: ['/tests/jest.setup.ts'], }; diff --git a/frontend/package.json b/frontend/package.json index 3faa04190..d7f9c3386 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/tests/ListWalker.test.ts b/frontend/tests/ListWalker.test.ts new file mode 100644 index 000000000..5fce51128 --- /dev/null +++ b/frontend/tests/ListWalker.test.ts @@ -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; + + beforeEach(() => { + walker = new ListWalker([]); + 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([{ 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([{ 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([{ 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([{ 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); + }); +}); \ No newline at end of file diff --git a/frontend/tests/MFileReader.test.ts b/frontend/tests/MFileReader.test.ts new file mode 100644 index 000000000..6fd931a56 --- /dev/null +++ b/frontend/tests/MFileReader.test.ts @@ -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); + }); +}); diff --git a/frontend/tests/MessageLoader.test.ts b/frontend/tests/MessageLoader.test.ts new file mode 100644 index 000000000..7272ef501 --- /dev/null +++ b/frontend/tests/MessageLoader.test.ts @@ -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 = {}; + 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); + }); +}); diff --git a/frontend/tests/TabManager.test.ts b/frontend/tests/TabManager.test.ts new file mode 100644 index 000000000..02da05f4f --- /dev/null +++ b/frontend/tests/TabManager.test.ts @@ -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; +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); +}); \ No newline at end of file diff --git a/frontend/tests/create.test.ts b/frontend/tests/create.test.ts new file mode 100644 index 000000000..080ffac5d --- /dev/null +++ b/frontend/tests/create.test.ts @@ -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); + }); +}); diff --git a/frontend/tests/featureFlagsStore.test.js b/frontend/tests/featureFlagsStore.test.js index 6b8ce2d1a..09e9cd1f1 100644 --- a/frontend/tests/featureFlagsStore.test.js +++ b/frontend/tests/featureFlagsStore.test.js @@ -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 }]; diff --git a/frontend/tests/jest.setup.ts b/frontend/tests/jest.setup.ts new file mode 100644 index 000000000..1a0fdb712 --- /dev/null +++ b/frontend/tests/jest.setup.ts @@ -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(), + })), +}); diff --git a/frontend/tests/mocks/sessionData.js b/frontend/tests/mocks/sessionData.ts similarity index 78% rename from frontend/tests/mocks/sessionData.js rename to frontend/tests/mocks/sessionData.ts index f679959ae..d7321a80b 100644 --- a/frontend/tests/mocks/sessionData.js +++ b/frontend/tests/mocks/sessionData.ts @@ -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, +}); diff --git a/frontend/tests/mocks/svgMock.js b/frontend/tests/mocks/svgMock.js new file mode 100644 index 000000000..c7908bf3c --- /dev/null +++ b/frontend/tests/mocks/svgMock.js @@ -0,0 +1,4 @@ +module.exports = { + __esModule: true, + default: 'SvgMock', +}; diff --git a/frontend/tests/searchStore.test.ts b/frontend/tests/searchStore.test.ts new file mode 100644 index 000000000..7ede4fd68 --- /dev/null +++ b/frontend/tests/searchStore.test.ts @@ -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]); + }); +}); \ No newline at end of file diff --git a/frontend/tests/sessionStore.test.ts b/frontend/tests/sessionStore.test.ts new file mode 100644 index 000000000..43ea9be18 --- /dev/null +++ b/frontend/tests/sessionStore.test.ts @@ -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', + ]); + }); + }); +}); diff --git a/frontend/tests/types.resource.test.ts b/frontend/tests/types.resource.test.ts index c90631a42..8fba7ab38 100644 --- a/frontend/tests/types.resource.test.ts +++ b/frontend/tests/types.resource.test.ts @@ -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, + ); }); }); diff --git a/frontend/tests/urlResolve.test.ts b/frontend/tests/urlResolve.test.ts index 87aa5f792..12c89f207 100644 --- a/frontend/tests/urlResolve.test.ts +++ b/frontend/tests/urlResolve.test.ts @@ -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 = [ diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 6d952d7b4..52532d96f 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -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" ] } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index fa8f7edad..37acfb81f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -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