import { DefaultPrivacyLevel, findLast } from '@openobserve/browser-core'
import type { RumConfiguration, ViewCreatedEvent } from '@openobserve/browser-rum-core'
import { LifeCycle, LifeCycleEventType } from '@openobserve/browser-rum-core'
import { createNewEvent, collectAsyncCalls, registerCleanupTask } from '@openobserve/browser-core/test'
import {
findElement,
findFullSnapshot,
findNode,
recordsPerFullSnapshot,
createRumFrustrationEvent,
} from '../../../test'
import type {
BrowserIncrementalSnapshotRecord,
BrowserMutationData,
BrowserRecord,
DocumentFragmentNode,
ElementNode,
ScrollData,
} from '../../types'
import { NodeType, RecordType, IncrementalSource } from '../../types'
import { appendElement } from '../../../../rum-core/test'
import { getReplayStats, resetReplayStats } from '../replayStats'
import type { RecordAPI } from './record'
import { record } from './record'
describe('record', () => {
let recordApi: RecordAPI
let lifeCycle: LifeCycle
let emitSpy: jasmine.Spy<(record: BrowserRecord) => void>
const FAKE_VIEW_ID = '123'
beforeEach(() => {
emitSpy = jasmine.createSpy()
registerCleanupTask(() => {
recordApi?.stop()
})
})
it('captures stylesheet rules', async () => {
const styleElement = appendElement('') as HTMLStyleElement
startRecording()
const styleSheet = styleElement.sheet as CSSStyleSheet
const ruleIdx0 = styleSheet.insertRule('body { background: #000; }')
const ruleIdx1 = styleSheet.insertRule('body { background: #111; }')
styleSheet.deleteRule(ruleIdx1)
setTimeout(() => {
styleSheet.insertRule('body { color: #fff; }')
}, 0)
setTimeout(() => {
styleSheet.deleteRule(ruleIdx0)
}, 5)
setTimeout(() => {
styleSheet.insertRule('body { color: #ccc; }')
}, 10)
await collectAsyncCalls(emitSpy, recordsPerFullSnapshot() + 6)
const records = getEmittedRecords()
let i = 0
expect(records[i++].type).toEqual(RecordType.Meta)
expect(records[i++].type).toEqual(RecordType.Focus)
expect(records[i++].type).toEqual(RecordType.FullSnapshot)
if (window.visualViewport) {
expect(records[i++].type).toEqual(RecordType.VisualViewport)
}
expect(records[i].type).toEqual(RecordType.IncrementalSnapshot)
expect((records[i++] as BrowserIncrementalSnapshotRecord).data).toEqual(
jasmine.objectContaining({
source: IncrementalSource.StyleSheetRule,
adds: [{ rule: 'body { background: #000; }', index: undefined }],
})
)
expect(records[i].type).toEqual(RecordType.IncrementalSnapshot)
expect((records[i++] as BrowserIncrementalSnapshotRecord).data).toEqual(
jasmine.objectContaining({
source: IncrementalSource.StyleSheetRule,
adds: [{ rule: 'body { background: #111; }', index: undefined }],
})
)
expect(records[i].type).toEqual(RecordType.IncrementalSnapshot)
expect((records[i++] as BrowserIncrementalSnapshotRecord).data).toEqual(
jasmine.objectContaining({
source: IncrementalSource.StyleSheetRule,
removes: [{ index: 0 }],
})
)
expect(records[i].type).toEqual(RecordType.IncrementalSnapshot)
expect((records[i++] as BrowserIncrementalSnapshotRecord).data).toEqual(
jasmine.objectContaining({
source: IncrementalSource.StyleSheetRule,
adds: [{ rule: 'body { color: #fff; }', index: undefined }],
})
)
expect(records[i].type).toEqual(RecordType.IncrementalSnapshot)
expect((records[i++] as BrowserIncrementalSnapshotRecord).data).toEqual(
jasmine.objectContaining({
source: IncrementalSource.StyleSheetRule,
removes: [{ index: 0 }],
})
)
expect(records[i].type).toEqual(RecordType.IncrementalSnapshot)
expect((records[i++] as BrowserIncrementalSnapshotRecord).data).toEqual(
jasmine.objectContaining({
source: IncrementalSource.StyleSheetRule,
adds: [{ rule: 'body { color: #ccc; }', index: undefined }],
})
)
})
it('flushes pending mutation records before taking a full snapshot', async () => {
startRecording()
appendElement('
')
// trigger full snapshot by starting a new view
newView()
await collectAsyncCalls(emitSpy, 1 + 2 * recordsPerFullSnapshot())
const records = getEmittedRecords()
let i = 0
expect(records[i++].type).toEqual(RecordType.Meta)
expect(records[i++].type).toEqual(RecordType.Focus)
expect(records[i++].type).toEqual(RecordType.FullSnapshot)
if (window.visualViewport) {
expect(records[i++].type).toEqual(RecordType.VisualViewport)
}
expect(records[i].type).toEqual(RecordType.IncrementalSnapshot)
expect((records[i++] as BrowserIncrementalSnapshotRecord).data.source).toEqual(IncrementalSource.Mutation)
expect(records[i++].type).toEqual(RecordType.Meta)
expect(records[i++].type).toEqual(RecordType.Focus)
expect(records[i++].type).toEqual(RecordType.FullSnapshot)
})
describe('Shadow dom', () => {
it('should record a simple mutation inside a shadow root', () => {
const element = appendElement('
', createShadow())
startRecording()
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot())
element.className = 'titi'
recordApi.flushMutations()
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot() + 1)
const innerMutationData = getLastIncrementalSnapshotData(
getEmittedRecords(),
IncrementalSource.Mutation
)
expect(innerMutationData.attributes[0].attributes.class).toBe('titi')
})
it('should record a direct removal inside a shadow root', () => {
const element = appendElement('
', createShadow())
startRecording()
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot())
element.remove()
recordApi.flushMutations()
const fs = findFullSnapshot({ records: getEmittedRecords() })!
const shadowRootNode = findNode(
fs.data.node,
(node) => node.type === NodeType.DocumentFragment && node.isShadowRoot
)!
expect(shadowRootNode).toBeTruthy()
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot() + 1)
const innerMutationData = getLastIncrementalSnapshotData(
getEmittedRecords(),
IncrementalSource.Mutation
)
expect(innerMutationData.removes.length).toBe(1)
expect(innerMutationData.removes[0].parentId).toBe(shadowRootNode.id)
})
it('should record a direct addition inside a shadow root', () => {
const shadowRoot = createShadow()
appendElement('
', shadowRoot)
startRecording()
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot())
appendElement('', shadowRoot)
recordApi.flushMutations()
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot() + 1)
const fs = findFullSnapshot({ records: getEmittedRecords() })!
const shadowRootNode = findNode(
fs.data.node,
(node) => node.type === NodeType.DocumentFragment && node.isShadowRoot
)!
expect(shadowRootNode).toBeTruthy()
const innerMutationData = getLastIncrementalSnapshotData(
getEmittedRecords(),
IncrementalSource.Mutation
)
expect(innerMutationData.adds.length).toBe(1)
expect(innerMutationData.adds[0].node.type).toBe(2)
expect(innerMutationData.adds[0].parentId).toBe(shadowRootNode.id)
const addedNode = innerMutationData.adds[0].node as ElementNode
expect(addedNode.tagName).toBe('span')
})
it('should record mutation inside a shadow root added after the FS', () => {
startRecording()
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot())
// shadow DOM mutation
const span = appendElement('', createShadow())
recordApi.flushMutations()
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot() + 1)
const hostMutationData = getLastIncrementalSnapshotData(
getEmittedRecords(),
IncrementalSource.Mutation
)
expect(hostMutationData.adds.length).toBe(1)
const hostNode = hostMutationData.adds[0].node as ElementNode
const shadowRoot = hostNode.childNodes[0] as DocumentFragmentNode
expect(shadowRoot.type).toBe(NodeType.DocumentFragment)
expect(shadowRoot.isShadowRoot).toBe(true)
// inner mutation
span.className = 'titi'
recordApi.flushMutations()
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot() + 2)
const innerMutationData = getLastIncrementalSnapshotData(
getEmittedRecords(),
IncrementalSource.Mutation
)
expect(innerMutationData.attributes.length).toBe(1)
expect(innerMutationData.attributes[0].attributes.class).toBe('titi')
})
it('should record the change event inside a shadow root', () => {
const radio = appendElement('', createShadow()) as HTMLInputElement
startRecording()
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot())
// inner mutation
radio.checked = true
radio.dispatchEvent(createNewEvent('change', { target: radio, composed: false }))
recordApi.flushMutations()
const innerMutationData = getLastIncrementalSnapshotData(
getEmittedRecords(),
IncrementalSource.Input
)
expect(innerMutationData.isChecked).toBe(true)
})
it('should record the change event inside a shadow root only once, regardless if the DOM is serialized multiple times', () => {
const radio = appendElement('', createShadow()) as HTMLInputElement
startRecording()
// trigger full snapshot by starting a new view
newView()
radio.checked = true
radio.dispatchEvent(createNewEvent('change', { target: radio, composed: false }))
const inputRecords = getEmittedRecords().filter(
(record) => record.type === RecordType.IncrementalSnapshot && record.data.source === IncrementalSource.Input
)
expect(inputRecords.length).toBe(1)
})
it('should record the scroll event inside a shadow root', () => {
const div = appendElement('', createShadow()) as HTMLDivElement
startRecording()
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot())
div.dispatchEvent(createNewEvent('scroll', { target: div, composed: false }))
recordApi.flushMutations()
const scrollRecords = getEmittedRecords().filter(
(record) => record.type === RecordType.IncrementalSnapshot && record.data.source === IncrementalSource.Scroll
)
expect(scrollRecords.length).toBe(1)
const scrollData = getLastIncrementalSnapshotData(getEmittedRecords(), IncrementalSource.Scroll)
const fs = findFullSnapshot({ records: getEmittedRecords() })!
const scrollableNode = findElement(fs.data.node, (node) => node.attributes['unique-selector'] === 'enabled')!
expect(scrollData.id).toBe(scrollableNode.id)
})
it('should clean the state once the shadow dom is removed to avoid memory leak', () => {
const shadowRoot = createShadow()
appendElement('', shadowRoot)
startRecording()
spyOn(recordApi.shadowRootsController, 'removeShadowRoot')
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot())
expect(recordApi.shadowRootsController.removeShadowRoot).toHaveBeenCalledTimes(0)
shadowRoot.host.remove()
recordApi.flushMutations()
expect(recordApi.shadowRootsController.removeShadowRoot).toHaveBeenCalledTimes(1)
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot() + 1)
const mutationData = getLastIncrementalSnapshotData(
getEmittedRecords(),
IncrementalSource.Mutation
)
expect(mutationData.removes.length).toBe(1)
})
it('should clean the state when both the parent and the shadow host is removed to avoid memory leak', () => {
const host = appendElement(`
`)
host.attachShadow({ mode: 'open' })
const parent = host.parentElement!
const grandParent = parent.parentElement!
appendElement('', host.shadowRoot!)
startRecording()
spyOn(recordApi.shadowRootsController, 'removeShadowRoot')
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot())
expect(recordApi.shadowRootsController.removeShadowRoot).toHaveBeenCalledTimes(0)
parent.remove()
grandParent.remove()
recordApi.flushMutations()
expect(recordApi.shadowRootsController.removeShadowRoot).toHaveBeenCalledTimes(1)
expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot() + 1)
const mutationData = getLastIncrementalSnapshotData(
getEmittedRecords(),
IncrementalSource.Mutation
)
expect(mutationData.removes.length).toBe(1)
})
function createShadow() {
const host = appendElement('')
const shadowRoot = host.attachShadow({ mode: 'open' })
return shadowRoot
}
})
describe('updates record replay stats', () => {
it('when recording new records', () => {
resetReplayStats()
startRecording()
const records = getEmittedRecords()
expect(getReplayStats(FAKE_VIEW_ID)?.records_count).toEqual(records.length)
})
})
describe('should collect records', () => {
let div: HTMLDivElement
let input: HTMLInputElement
let audio: HTMLAudioElement
beforeEach(() => {
div = appendElement('') as HTMLDivElement
input = appendElement('') as HTMLInputElement
audio = appendElement('') as HTMLAudioElement
startRecording()
emitSpy.calls.reset()
})
it('move', () => {
document.body.dispatchEvent(createNewEvent('mousemove', { clientX: 1, clientY: 2 }))
expect(getEmittedRecords()[0].type).toBe(RecordType.IncrementalSnapshot)
expect((getEmittedRecords()[0] as BrowserIncrementalSnapshotRecord).data.source).toBe(IncrementalSource.MouseMove)
})
it('interaction', () => {
document.body.dispatchEvent(createNewEvent('click', { clientX: 1, clientY: 2 }))
expect((getEmittedRecords()[0] as BrowserIncrementalSnapshotRecord).data.source).toBe(
IncrementalSource.MouseInteraction
)
})
it('scroll', () => {
div.dispatchEvent(createNewEvent('scroll', { target: div }))
expect(getEmittedRecords()[0].type).toBe(RecordType.IncrementalSnapshot)
expect((getEmittedRecords()[0] as BrowserIncrementalSnapshotRecord).data.source).toBe(IncrementalSource.Scroll)
})
it('viewport resize', () => {
window.dispatchEvent(createNewEvent('resize'))
expect(getEmittedRecords()[0].type).toBe(RecordType.IncrementalSnapshot)
expect((getEmittedRecords()[0] as BrowserIncrementalSnapshotRecord).data.source).toBe(
IncrementalSource.ViewportResize
)
})
it('input', () => {
input.value = 'newValue'
input.dispatchEvent(createNewEvent('input', { target: input }))
expect(getEmittedRecords()[0].type).toBe(RecordType.IncrementalSnapshot)
expect((getEmittedRecords()[0] as BrowserIncrementalSnapshotRecord).data.source).toBe(IncrementalSource.Input)
})
it('media interaction', () => {
audio.dispatchEvent(createNewEvent('play', { target: audio }))
expect(getEmittedRecords()[0].type).toBe(RecordType.IncrementalSnapshot)
expect((getEmittedRecords()[0] as BrowserIncrementalSnapshotRecord).data.source).toBe(
IncrementalSource.MediaInteraction
)
})
it('focus', () => {
window.dispatchEvent(createNewEvent('blur'))
expect(getEmittedRecords()[0].type).toBe(RecordType.Focus)
})
it('visual viewport resize', () => {
if (!window.visualViewport) {
pending('visualViewport not supported')
}
visualViewport!.dispatchEvent(createNewEvent('resize'))
expect(getEmittedRecords()[0].type).toBe(RecordType.VisualViewport)
})
it('frustration', () => {
lifeCycle.notify(
LifeCycleEventType.RAW_RUM_EVENT_COLLECTED,
createRumFrustrationEvent(new MouseEvent('pointerup'))
)
expect(getEmittedRecords()[0].type).toBe(RecordType.FrustrationRecord)
})
it('view end event', () => {
lifeCycle.notify(LifeCycleEventType.VIEW_ENDED, {} as any)
expect(getEmittedRecords()[0].type).toBe(RecordType.ViewEnd)
})
})
function startRecording() {
lifeCycle = new LifeCycle()
recordApi = record({
emit: emitSpy,
configuration: { defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW } as RumConfiguration,
lifeCycle,
viewHistory: {
findView: () => ({ id: FAKE_VIEW_ID, startClocks: {} }),
} as any,
})
}
function newView() {
lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, {
startClocks: { relative: 0, timeStamp: 0 },
} as ViewCreatedEvent)
}
function getEmittedRecords() {
return emitSpy.calls.allArgs().map(([record]) => record)
}
})
export function getLastIncrementalSnapshotData(
records: BrowserRecord[],
source: IncrementalSource
): T {
const record = findLast(
records,
(record): record is BrowserIncrementalSnapshotRecord & { data: T } =>
record.type === RecordType.IncrementalSnapshot && record.data.source === source
)
expect(record).toBeTruthy(`Could not find IncrementalSnapshot/${source} in ${records.length} records`)
return record!.data
}