From 1b70d463d0590b1c6a714d4b93db50cfd171cde2 Mon Sep 17 00:00:00 2001 From: nick-delirium Date: Tue, 12 Dec 2023 13:01:29 +0100 Subject: [PATCH] fix(tracker): split uxt ui and signals, cover signal manager with tests --- tracker/tracker/package.json | 2 +- .../main/modules/userTesting/SignalManager.ts | 95 +++++++ .../src/main/modules/userTesting/index.ts | 244 ++++-------------- .../src/main/modules/userTesting/styles.ts | 9 + .../src/main/modules/userTesting/utils.ts | 93 +++++++ .../tracker/src/tests/signalManager.test.ts | 104 ++++++++ 6 files changed, 358 insertions(+), 189 deletions(-) create mode 100644 tracker/tracker/src/main/modules/userTesting/SignalManager.ts create mode 100644 tracker/tracker/src/main/modules/userTesting/utils.ts create mode 100644 tracker/tracker/src/tests/signalManager.test.ts diff --git a/tracker/tracker/package.json b/tracker/tracker/package.json index e7e18485d..b4b376c37 100644 --- a/tracker/tracker/package.json +++ b/tracker/tracker/package.json @@ -1,7 +1,7 @@ { "name": "@openreplay/tracker", "description": "The OpenReplay tracker main package", - "version": "11.0.2-20", + "version": "11.0.2-26", "keywords": [ "logging", "replay" diff --git a/tracker/tracker/src/main/modules/userTesting/SignalManager.ts b/tracker/tracker/src/main/modules/userTesting/SignalManager.ts new file mode 100644 index 000000000..c783e3681 --- /dev/null +++ b/tracker/tracker/src/main/modules/userTesting/SignalManager.ts @@ -0,0 +1,95 @@ +import { TEST_START, TASK_IND, SESSION_ID } from './utils.js' + +export default class SignalManager { + private readonly durations = { + testStart: 0, + tasks: [] as unknown as { + taskId: number + started: number + }[], + } + + constructor( + private readonly ingestPoint: string, + private readonly getTimestamp: () => number, + private readonly token: string, + private readonly testId: number, + private readonly storageKey: string, + private readonly setStorageKey: (key: string, value: string) => void, + private readonly removeStorageKey: (key: string) => void, + private readonly getStorageKey: (key: string) => string | null, + private readonly getSessionId: () => string | undefined, + ) { + const possibleStart = this.getStorageKey(TEST_START) + if (possibleStart) { + this.durations.testStart = parseInt(possibleStart, 10) + } + } + + getDurations = () => { + return this.durations + } + + setDurations = (durations: { + testStart: number + tasks: { + taskId: number + started: number + }[] + }) => { + this.durations.testStart = durations.testStart + this.durations.tasks = durations.tasks + } + + signalTask = (taskId: number, status: 'begin' | 'done' | 'skipped', taskAnswer?: string) => { + if (!taskId) return console.error('User Testing: No Task ID Given') + const taskStart = this.durations.tasks.find((t) => t.taskId === taskId) + const timestamp = this.getTimestamp() + const duration = taskStart ? timestamp - taskStart.started : 0 + return fetch(`${this.ingestPoint}/v1/web/uxt/signals/task`, { + method: 'POST', + headers: { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + Authorization: `Bearer ${this.token}`, + }, + body: JSON.stringify({ + testId: this.testId, + taskId, + status, + duration, + timestamp, + taskAnswer, + }), + }) + } + + signalTest = (status: 'begin' | 'done' | 'skipped') => { + const timestamp = this.getTimestamp() + if (status === 'begin' && this.testId) { + const sessionId = this.getSessionId() + this.setStorageKey(SESSION_ID, sessionId as unknown as string) + this.setStorageKey(this.storageKey, this.testId.toString()) + this.setStorageKey(TEST_START, timestamp.toString()) + } else { + this.removeStorageKey(this.storageKey) + this.removeStorageKey(TASK_IND) + this.removeStorageKey(TEST_START) + } + const start = this.durations.testStart || timestamp + const duration = timestamp - start + + return fetch(`${this.ingestPoint}/v1/web/uxt/signals/test`, { + method: 'POST', + headers: { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + Authorization: `Bearer ${this.token}`, + }, + body: JSON.stringify({ + testId: this.testId, + status, + duration, + timestamp, + }), + }) + } +} diff --git a/tracker/tracker/src/main/modules/userTesting/index.ts b/tracker/tracker/src/main/modules/userTesting/index.ts index 5145fe09c..dc88307b0 100644 --- a/tracker/tracker/src/main/modules/userTesting/index.ts +++ b/tracker/tracker/src/main/modules/userTesting/index.ts @@ -2,25 +2,17 @@ import App from '../../app/index.js' import * as styles from './styles.js' import Recorder, { Quality } from './recorder.js' import attachDND from './dnd.js' - -function createElement( - tag: string, - className: string, - styles: any, - textContent?: string, - id?: string, -) { - const element = document.createElement(tag) - element.className = className - Object.assign(element.style, styles) - if (textContent) { - element.textContent = textContent - } - if (id) { - element.id = id - } - return element -} +import { + generateGrid, + generateChevron, + createSpinner, + createElement, + TEST_START, + TASK_IND, + SESSION_ID, + TEST_ID, +} from './utils.js' +import SignalManager from './SignalManager.js' interface Test { title: string @@ -57,16 +49,10 @@ export default class UserTestManager { private taskSection: HTMLElement | null = null private endSection: HTMLElement | null = null private stopButton: HTMLElement | null = null + private stopButtonContainer: HTMLElement | null = null private test: Test | null = null private testId: number | null = null - private token: string | null = null - private readonly durations = { - testStart: 0, - tasks: [] as unknown as { - taskId: number - started: number - }[], - } + private signalManager: SignalManager | null = null constructor( private readonly app: App, @@ -74,23 +60,19 @@ export default class UserTestManager { ) { this.userRecorder = new Recorder(app) const sessionId = this.app.getSessionID() - const savedSessionId = this.app.localStorage.getItem('or_uxt_session_id') + const savedSessionId = this.app.localStorage.getItem(SESSION_ID) console.log(sessionId, savedSessionId) if (sessionId !== savedSessionId) { this.app.localStorage.removeItem(this.storageKey) - this.app.localStorage.removeItem('or_uxt_session_id') - this.app.localStorage.removeItem('or_uxt_test_id') - this.app.localStorage.removeItem('or_uxt_task_index') - this.app.localStorage.removeItem('or_uxt_test_start') + this.app.localStorage.removeItem(SESSION_ID) + this.app.localStorage.removeItem(TEST_ID) + this.app.localStorage.removeItem(TASK_IND) + this.app.localStorage.removeItem(TEST_START) } - const taskIndex = this.app.localStorage.getItem('or_uxt_task_index') + const taskIndex = this.app.localStorage.getItem(TASK_IND) if (taskIndex) { this.currentTaskIndex = parseInt(taskIndex, 10) - this.durations.testStart = parseInt( - this.app.localStorage.getItem('or_uxt_test_start') as string, - 10, - ) } } @@ -98,63 +80,8 @@ export default class UserTestManager { return this.testId } - signalTask = (taskId: number, status: 'begin' | 'done' | 'skipped', answer?: string) => { - if (!taskId) return console.error('OR: no task id') - const taskStart = this.durations.tasks.find((t) => t.taskId === taskId) - const timestamp = this.app.timestamp() - const duration = taskStart ? timestamp - taskStart.started : 0 - const ingest = this.app.options.ingestPoint - return fetch(`${ingest}/v1/web/uxt/signals/task`, { - method: 'POST', - headers: { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - Authorization: `Bearer ${this.token}`, - }, - body: JSON.stringify({ - testId: this.testId, - taskId, - status, - duration, - timestamp, - answer, - }), - }) - } - - signalTest = (status: 'begin' | 'done' | 'skipped') => { - const timestamp = this.app.timestamp() - if (status === 'begin' && this.testId) { - const sessionId = this.app.getSessionID() - this.app.localStorage.setItem('or_uxt_session_id', sessionId as unknown as string) - this.app.localStorage.setItem(this.storageKey, this.testId.toString()) - this.app.localStorage.setItem('or_uxt_test_start', timestamp.toString()) - } else { - this.app.localStorage.removeItem(this.storageKey) - this.app.localStorage.removeItem('or_uxt_task_index') - this.app.localStorage.removeItem('or_uxt_test_start') - } - const ingest = this.app.options.ingestPoint - const start = this.durations.testStart || timestamp - const duration = timestamp - start - - return fetch(`${ingest}/v1/web/uxt/signals/test`, { - method: 'POST', - headers: { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - Authorization: `Bearer ${this.token}`, - }, - body: JSON.stringify({ - testId: this.testId, - status, - duration, - timestamp, - }), - }) - } - getTest = (id: number, token: string, inProgress?: boolean) => { this.testId = id - this.token = token const ingest = this.app.options.ingestPoint return fetch(`${ingest}/v1/web/uxt/test/${id}`, { headers: { @@ -165,6 +92,17 @@ export default class UserTestManager { .then(({ test }: { test: Test }) => { this.isActive = true this.test = test + this.signalManager = new SignalManager( + this.app.options.ingestPoint, + () => this.app.timestamp(), + token, + id, + this.storageKey, + (k: string, v: string) => this.app.localStorage.setItem(k, v), + (k) => this.app.localStorage.removeItem(k), + (k) => this.app.localStorage.getItem(k), + () => this.app.getSessionID(), + ) this.createGreeting(test.title, test.reqMic, test.reqCamera) if (inProgress) { if (test.reqMic || test.reqCamera) { @@ -214,8 +152,12 @@ export default class UserTestManager { } buttonElement.onclick = () => { this.removeGreeting() - this.durations.testStart = this.app.timestamp() - void this.signalTest('begin') + const durations = this.signalManager?.getDurations() + if (durations && this.signalManager) { + durations.testStart = this.app.timestamp() + this.signalManager.setDurations(durations) + } + void this.signalManager?.signalTest('begin') this.container.style.fontFamily = `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"` Object.assign(this.container.style, styles.containerWidgetStyle) this.showWidget(this.test?.guidelines || '', this.test?.tasks || []) @@ -265,9 +207,10 @@ export default class UserTestManager { this.taskSection = tasksSection this.descriptionSection = descriptionSection this.stopButton = stopButton + this.stopButtonContainer = stopContainer stopButton.onclick = () => { this.userRecorder.discard() - void this.signalTest('skipped') + void this.signalManager?.signalTest('skipped') document.body.removeChild(this.bg) window.close() } @@ -404,17 +347,18 @@ export default class UserTestManager { button.onclick = () => { toggleDescriptionVisibility() if (this.test) { - if ( - this.durations.tasks.findIndex( - (t) => this.test && t.taskId === this.test.tasks[0].task_id, - ) === -1 - ) { - this.durations.tasks.push({ + const durations = this.signalManager?.getDurations() + const taskDurationInd = durations + ? durations.tasks.findIndex((t) => this.test && t.taskId === this.test.tasks[0].task_id) + : null + if (durations && taskDurationInd === -1) { + durations.tasks.push({ taskId: this.test.tasks[0].task_id, started: this.app.timestamp(), }) + this.signalManager?.setDurations(durations) } - void this.signalTask(this.test.tasks[0].task_id, 'begin') + void this.signalManager?.signalTask(this.test.tasks[0].task_id, 'begin') } this.showTaskSection() content.removeChild(button) @@ -558,21 +502,22 @@ export default class UserTestManager { nextButton.onclick = () => { const textAnswer = tasks[this.currentTaskIndex].allow_typing ? inputArea.value : undefined inputArea.value = '' - void this.signalTask(tasks[this.currentTaskIndex].task_id, 'done', textAnswer) + void this.signalManager?.signalTask(tasks[this.currentTaskIndex].task_id, 'done', textAnswer) if (this.currentTaskIndex < tasks.length - 1) { this.currentTaskIndex++ updateTaskContent() + const durations = this.signalManager?.getDurations() if ( - this.durations.tasks.findIndex( - (t) => t.taskId === tasks[this.currentTaskIndex].task_id, - ) === -1 + durations && + durations.tasks.findIndex((t) => t.taskId === tasks[this.currentTaskIndex].task_id) === -1 ) { - this.durations.tasks.push({ + durations.tasks.push({ taskId: tasks[this.currentTaskIndex].task_id, started: this.app.timestamp(), }) + this.signalManager?.setDurations(durations) } - void this.signalTask(tasks[this.currentTaskIndex].task_id, 'begin') + void this.signalManager?.signalTask(tasks[this.currentTaskIndex].task_id, 'begin') highlightActive() } else { this.showEndSection() @@ -593,7 +538,7 @@ export default class UserTestManager { showEndSection() { let isLoading = true - void this.signalTest('done') + void this.signalManager?.signalTest('done') const section = createElement('div', 'end_section_or', styles.endSectionStyle) const title = createElement( 'div', @@ -648,8 +593,8 @@ export default class UserTestManager { if (this.descriptionSection) { this.container.removeChild(this.descriptionSection) } - if (this.stopButton) { - this.container.removeChild(this.stopButton) + if (this.stopButton && this.stopButtonContainer) { + this.container.removeChild(this.stopButtonContainer) } button.onclick = () => { @@ -662,80 +607,3 @@ export default class UserTestManager { this.container.append(section) } } - -function generateGrid() { - const grid = document.createElement('div') - grid.className = 'grid' - for (let i = 0; i < 16; i++) { - const cell = document.createElement('div') - Object.assign(cell.style, { - width: '2px', - height: '2px', - borderRadius: '10px', - background: 'white', - }) - cell.className = 'cell' - grid.appendChild(cell) - } - Object.assign(grid.style, { - display: 'grid', - gridTemplateColumns: 'repeat(4, 1fr)', - gridTemplateRows: 'repeat(4, 1fr)', - gap: '2px', - cursor: 'grab', - }) - return grid -} - -function generateChevron() { - const triangle = document.createElement('div') - Object.assign(triangle.style, { - width: '0', - height: '0', - borderLeft: '7px solid transparent', - borderRight: '7px solid transparent', - borderBottom: '7px solid white', - }) - const container = document.createElement('div') - container.appendChild(triangle) - Object.assign(container.style, { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: '16px', - height: '16px', - cursor: 'pointer', - marginLeft: 'auto', - transform: 'rotate(180deg)', - }) - return container -} - -const spinnerStyles = { - border: '4px solid rgba(255, 255, 255, 0.4)', - width: '16px', - height: '16px', - borderRadius: '50%', - borderLeftColor: '#fff', - animation: 'spin 0.5s linear infinite', -} - -function addKeyframes() { - const styleSheet = document.createElement('style') - styleSheet.type = 'text/css' - styleSheet.innerText = `@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } - }` - document.head.appendChild(styleSheet) -} - -function createSpinner() { - addKeyframes() - const spinner = document.createElement('div') - spinner.classList.add('spinner') - - Object.assign(spinner.style, spinnerStyles) - - return spinner -} diff --git a/tracker/tracker/src/main/modules/userTesting/styles.ts b/tracker/tracker/src/main/modules/userTesting/styles.ts index 44f5a725e..14965dffa 100644 --- a/tracker/tracker/src/main/modules/userTesting/styles.ts +++ b/tracker/tracker/src/main/modules/userTesting/styles.ts @@ -273,3 +273,12 @@ export const taskButtonsRow = { width: '100%', boxSizing: 'border-box', } + +export const spinnerStyles = { + border: '4px solid rgba(255, 255, 255, 0.4)', + width: '16px', + height: '16px', + borderRadius: '50%', + borderLeftColor: '#fff', + animation: 'spin 0.5s linear infinite', +} diff --git a/tracker/tracker/src/main/modules/userTesting/utils.ts b/tracker/tracker/src/main/modules/userTesting/utils.ts new file mode 100644 index 000000000..7ec365315 --- /dev/null +++ b/tracker/tracker/src/main/modules/userTesting/utils.ts @@ -0,0 +1,93 @@ +import { spinnerStyles } from './styles.js' + +export function generateGrid() { + const grid = document.createElement('div') + grid.className = 'grid' + for (let i = 0; i < 16; i++) { + const cell = document.createElement('div') + Object.assign(cell.style, { + width: '2px', + height: '2px', + borderRadius: '10px', + background: 'white', + }) + cell.className = 'cell' + grid.appendChild(cell) + } + Object.assign(grid.style, { + display: 'grid', + gridTemplateColumns: 'repeat(4, 1fr)', + gridTemplateRows: 'repeat(4, 1fr)', + gap: '2px', + cursor: 'grab', + }) + return grid +} + +export function generateChevron() { + const triangle = document.createElement('div') + Object.assign(triangle.style, { + width: '0', + height: '0', + borderLeft: '7px solid transparent', + borderRight: '7px solid transparent', + borderBottom: '7px solid white', + }) + const container = document.createElement('div') + container.appendChild(triangle) + Object.assign(container.style, { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '16px', + height: '16px', + cursor: 'pointer', + marginLeft: 'auto', + transform: 'rotate(180deg)', + }) + return container +} + +export function addKeyframes() { + const styleSheet = document.createElement('style') + styleSheet.type = 'text/css' + styleSheet.innerText = `@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + }` + document.head.appendChild(styleSheet) +} + +export function createSpinner() { + addKeyframes() + const spinner = document.createElement('div') + spinner.classList.add('spinner') + + Object.assign(spinner.style, spinnerStyles) + + return spinner +} + +export function createElement( + tag: string, + className: string, + styles: any, + textContent?: string, + id?: string, +) { + const element = document.createElement(tag) + element.className = className + Object.assign(element.style, styles) + if (textContent) { + element.textContent = textContent + } + if (id) { + element.id = id + } + return element +} + +export const TEST_START = 'or_uxt_test_start' +export const TASK_IND = 'or_uxt_task_index' +export const SESSION_ID = 'or_uxt_session_id' +export const TEST_ID = 'or_uxt_test_id' diff --git a/tracker/tracker/src/tests/signalManager.test.ts b/tracker/tracker/src/tests/signalManager.test.ts new file mode 100644 index 000000000..9c062a127 --- /dev/null +++ b/tracker/tracker/src/tests/signalManager.test.ts @@ -0,0 +1,104 @@ +// @ts-nocheck +import SignalManager from '../main/modules/userTesting/SignalManager' +import { TEST_START, SESSION_ID } from '../main/modules/userTesting/utils' +import { jest, describe, beforeEach, expect, test } from '@jest/globals' + +global.fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({}), + }), +) + +const localStorageMock = (() => { + let store = {} + return { + getItem: jest.fn((key) => store[key] || null), + setItem: jest.fn((key, value) => { + store[key] = value.toString() + }), + removeItem: jest.fn((key) => { + delete store[key] + }), + clear: jest.fn(() => { + store = {} + }), + } +})() + +Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + +const ingestPoint = 'https://example.com' +const getTimestamp = jest.fn() +const token = 'test-token' +const testId = 'testId' +const storageKey = 'test-storage-key' +const setStorageKey = jest.fn() +const removeStorageKey = jest.fn() +const getStorageKey = jest.fn() +const getSessionId = jest.fn() + +let signalManager + +beforeEach(() => { + signalManager = new SignalManager( + ingestPoint, + getTimestamp, + token, + testId, + storageKey, + setStorageKey, + removeStorageKey, + getStorageKey, + getSessionId, + ) + + // Reset mocks + fetch.mockClear() + localStorageMock.clear() + getTimestamp.mockClear() + setStorageKey.mockClear() + removeStorageKey.mockClear() + getStorageKey.mockClear() + getSessionId.mockClear() +}) + +describe('UXT SignalManager tests', () => { + test('Constructor initializes durations from local storage', () => { + getStorageKey.mockReturnValueOnce('1000') + const localSignalManager = new SignalManager( + ingestPoint, + getTimestamp, + token, + testId, + storageKey, + setStorageKey, + removeStorageKey, + getStorageKey, + getSessionId, + ) + expect(localSignalManager.getDurations().testStart).toBe(1000) + }) + + test('signalTask sends correct data for valid task ID', async () => { + getTimestamp.mockReturnValueOnce(2000) + signalManager.setDurations({ testStart: 1000, tasks: [{ taskId: 1, started: 1500 }] }) + await signalManager.signalTask(1, 'done') + expect(fetch).toHaveBeenCalledWith(`${ingestPoint}/v1/web/uxt/signals/task`, expect.anything()) + expect(fetch.mock.calls[0][1].body).toEqual(expect.stringContaining('"taskId":1')) + }) + + test('signalTask handles missing taskId', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + signalManager.signalTask(0, 'done') + expect(consoleSpy).toHaveBeenCalledWith('User Testing: No Task ID Given') + consoleSpy.mockRestore() + }) + + test('signalTest sets storage keys on test begin', () => { + getTimestamp.mockReturnValueOnce(3000) + getSessionId.mockReturnValueOnce('session-id') + signalManager.signalTest('begin') + expect(setStorageKey).toHaveBeenCalledWith(SESSION_ID, 'session-id') + expect(setStorageKey).toHaveBeenCalledWith(TEST_START, '3000') + }) +})