fix(tracker): split uxt ui and signals, cover signal manager with tests

This commit is contained in:
nick-delirium 2023-12-12 13:01:29 +01:00
parent 44dc49b135
commit 1b70d463d0
6 changed files with 358 additions and 189 deletions

View file

@ -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"

View file

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

View file

@ -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
}

View file

@ -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',
}

View file

@ -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'

View file

@ -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')
})
})