From 69a8e00ce66c41a29f551697299246763e7ae29f Mon Sep 17 00:00:00 2001 From: Jordan Porter Date: Wed, 24 Jan 2024 15:41:43 -0700 Subject: [PATCH] feat: Session Replay - Detect Non-Inlined Stylesheets (#859) --- .../session_replay/aggregate/index.js | 3 + .../session_replay/shared/recorder-events.js | 2 + .../session_replay/shared/recorder.js | 53 ++++++++++- .../shared/stylesheet-evaluator.js | 84 ++++++++++++++++ .../shared/stylesheet-evaluator.test.js | 95 +++++++++++++++++++ tests/assets/rrweb-instrumented.html | 16 ++-- tests/assets/rrweb-invalid-stylesheet.html | 56 +++++++++++ tests/assets/rrweb-record.html | 10 +- tests/specs/session-replay/helpers.js | 3 +- tests/specs/session-replay/mode.e2e.js | 30 +++--- tests/specs/session-replay/payload.e2e.js | 46 ++++++++- .../session-replay/rrweb-configuration.e2e.js | 8 +- tools/testing-server/test-handle.js | 11 ++- tools/testing-server/utils/expect-tests.js | 8 ++ .../testing-server/test-handle-connector.mjs | 10 +- 15 files changed, 396 insertions(+), 39 deletions(-) create mode 100644 src/features/session_replay/shared/stylesheet-evaluator.js create mode 100644 src/features/session_replay/shared/stylesheet-evaluator.test.js create mode 100644 tests/assets/rrweb-invalid-stylesheet.html diff --git a/src/features/session_replay/aggregate/index.js b/src/features/session_replay/aggregate/index.js index aee3168ee..b0326e4a6 100644 --- a/src/features/session_replay/aggregate/index.js +++ b/src/features/session_replay/aggregate/index.js @@ -26,6 +26,7 @@ import { RRWEB_VERSION } from '../../../common/constants/env' import { now } from '../../../common/timing/now' import { MODE, SESSION_EVENTS, SESSION_EVENT_TYPES } from '../../../common/session/constants' import { stringify } from '../../../common/util/stringify' +import { stylesheetEvaluator } from '../shared/stylesheet-evaluator' let gzipper, u8 @@ -302,6 +303,8 @@ export class Aggregate extends AggregateBase { hasError: recorderEvents.hasError || false, isFirstChunk: agentRuntime.session.state.sessionReplaySentFirstChunk === false, decompressedBytes: recorderEvents.payloadBytesEstimation, + invalidStylesheetsDetected: stylesheetEvaluator.invalidStylesheetsDetected, + inlinedAllStylesheets: recorderEvents.inlinedAllStylesheets, 'rrweb.version': RRWEB_VERSION, // customer-defined data should go last so that if it exceeds the query param padding limit it will be truncated instead of important attrs ...(endUserId && { 'enduser.id': endUserId }) diff --git a/src/features/session_replay/shared/recorder-events.js b/src/features/session_replay/shared/recorder-events.js index 1b6a3c4c2..962cd5408 100644 --- a/src/features/session_replay/shared/recorder-events.js +++ b/src/features/session_replay/shared/recorder-events.js @@ -17,6 +17,8 @@ export class RecorderEvents { this.hasMeta = false /** Payload metadata -- Should indicate that the payload being sent contains an error. Used for query/filter purposes in UI */ this.hasError = false + /** Payload metadata -- Denotes whether all stylesheet elements were able to be inlined */ + this.inlinedAllStylesheets = true } add (event) { diff --git a/src/features/session_replay/shared/recorder.js b/src/features/session_replay/shared/recorder.js index a4f5eb834..461513fa3 100644 --- a/src/features/session_replay/shared/recorder.js +++ b/src/features/session_replay/shared/recorder.js @@ -4,6 +4,10 @@ import { AVG_COMPRESSION, CHECKOUT_MS, IDEAL_PAYLOAD_SIZE, QUERY_PARAM_PADDING, import { getConfigurationValue } from '../../../common/config/config' import { RecorderEvents } from './recorder-events' import { MODE } from '../../../common/session/constants' +import { stylesheetEvaluator } from './stylesheet-evaluator' +import { handle } from '../../../common/event-emitter/handle' +import { SUPPORTABILITY_METRIC_CHANNEL } from '../../metrics/constants' +import { FEATURE_NAMES } from '../../../loaders/features/features' export class Recorder { /** Each page mutation or event will be stored (raw) in this array. This array will be cleared on each harvest */ @@ -12,16 +16,22 @@ export class Recorder { #backloggedEvents = new RecorderEvents() /** array of recorder events -- Will be filled only if forced harvest was triggered and harvester does not exist */ #preloaded = [new RecorderEvents()] + /** flag that if true, blocks events from being "stored". Only set to true when a full snapshot has incomplete nodes (only stylesheets ATM) */ + #fixing = false constructor (parent) { /** True when actively recording, false when paused or stopped */ this.recording = false + /** The pointer to the current bucket holding rrweb events */ this.currentBufferTarget = this.#events /** Hold on to the last meta node, so that it can be re-inserted if the meta and snapshot nodes are broken up due to harvesting */ this.lastMeta = false - + /** The parent class that instantiated the recorder */ this.parent = parent - + /** Config to inform to inline stylesheet contents (true default) */ + this.shouldInlineStylesheets = getConfigurationValue(this.parent.agentIdentifier, 'session_replay.inline_stylesheet') + /** A flag that can be set to false by failing conversions to stop the fetching process */ + this.shouldFix = this.shouldInlineStylesheets /** The method to stop recording. This defaults to a noop, but is overwritten once the recording library is imported and initialized */ this.stopRecording = () => { /* no-op until set by rrweb initializer */ } } @@ -35,7 +45,8 @@ export class Recorder { payloadBytesEstimation: this.#backloggedEvents.payloadBytesEstimation + this.#events.payloadBytesEstimation, hasError: this.#backloggedEvents.hasError || this.#events.hasError, hasMeta: this.#backloggedEvents.hasMeta || this.#events.hasMeta, - hasSnapshot: this.#backloggedEvents.hasSnapshot || this.#events.hasSnapshot + hasSnapshot: this.#backloggedEvents.hasSnapshot || this.#events.hasSnapshot, + inlinedAllStylesheets: (!!this.#backloggedEvents.events.length && this.#backloggedEvents.inlinedAllStylesheets) || this.#events.inlinedAllStylesheets } } @@ -54,7 +65,7 @@ export class Recorder { // set up rrweb configurations for maximum privacy -- // https://newrelic.atlassian.net/wiki/spaces/O11Y/pages/2792293280/2023+02+28+Browser+-+Session+Replay#Configuration-options const stop = recorder({ - emit: this.store.bind(this), + emit: this.audit.bind(this), blockClass: block_class, ignoreClass: ignore_class, maskTextClass: mask_text_class, @@ -74,8 +85,42 @@ export class Recorder { } } + /** + * audit - Checks if the event node payload is missing certain attributes + * will forward on to the "store" method if nothing needs async fixing + * @param {*} event - An RRWEB event node + * @param {*} isCheckout - Flag indicating if the payload was triggered as a checkout + */ + audit (event, isCheckout) { + /** only run the audit if inline_stylesheets is configured as on (default behavior) */ + if (this.shouldInlineStylesheets === false || !this.shouldFix) { + this.currentBufferTarget.inlinedAllStylesheets = false + return this.store(event, isCheckout) + } + /** An count of stylesheet objects that were blocked from accessing contents via JS */ + const incompletes = stylesheetEvaluator.evaluate() + /** Only stop ignoring data if already ignoring and a new valid snapshap is taking place (0 incompletes and we get a meta node for the snap) */ + if (!incompletes && this.#fixing && event.type === RRWEB_EVENT_TYPES.Meta) this.#fixing = false + if (incompletes) { + handle(SUPPORTABILITY_METRIC_CHANNEL, ['SessionReplay/Payload/Missing-Inline-Css', incompletes], undefined, FEATURE_NAMES.metrics, this.parent.ee) + /** wait for the evaluator to download/replace the incompletes' src code and then take a new snap */ + stylesheetEvaluator.fix().then((failedToFix) => { + if (failedToFix) { + this.currentBufferTarget.inlinedAllStylesheets = false + this.shouldFix = false + } + this.takeFullSnapshot() + }) + /** Only start ignoring data if got a faulty snapshot */ + if (event.type === RRWEB_EVENT_TYPES.FullSnapshot || event.type === RRWEB_EVENT_TYPES.Meta) this.#fixing = true + } + /** Only store the data if not being "fixed" (full snapshots that have broken css) */ + if (!this.#fixing) this.store(event, isCheckout) + } + /** Store a payload in the buffer (this.#events). This should be the callback to the recording lib noticing a mutation */ store (event, isCheckout) { + if (!event) return event.__serialized = stringify(event) if (!this.parent.scheduler && this.#preloaded.length) this.currentBufferTarget = this.#preloaded[this.#preloaded.length - 1] diff --git a/src/features/session_replay/shared/stylesheet-evaluator.js b/src/features/session_replay/shared/stylesheet-evaluator.js new file mode 100644 index 000000000..8f2ae7e06 --- /dev/null +++ b/src/features/session_replay/shared/stylesheet-evaluator.js @@ -0,0 +1,84 @@ +import { originals } from '../../../common/config/config' +import { isBrowserScope } from '../../../common/constants/runtime' + +class StylesheetEvaluator { + #evaluated = new WeakSet() + #fetchProms = [] + /** + * Flipped to true if stylesheets that cannot be natively inlined are detected by the stylesheetEvaluator class + * Used at harvest time to denote that all subsequent payloads are subject to this and customers should be advised to handle crossorigin decoration + * */ + invalidStylesheetsDetected = false + failedToFix = false + + /** + * this works by checking (only ever once) each cssRules obj in the style sheets array. The try/catch will catch an error if the cssRules obj blocks access, triggering the module to try to "fix" the asset`. Returns the count of incomplete assets discovered. + * @returns {Number} + */ + evaluate () { + let incompletes = 0 + if (isBrowserScope) { + for (let i = 0; i < Object.keys(document.styleSheets).length; i++) { + const ss = document.styleSheets[i] + if (!this.#evaluated.has(ss)) { + this.#evaluated.add(ss) + try { + // eslint-disable-next-line + const temp = ss.cssRules + } catch (err) { + incompletes++ + this.#fetchProms.push(this.#fetchAndOverride(document.styleSheets[i], ss.href)) + } + } + } + } + if (incompletes) this.invalidStylesheetsDetected = true + return incompletes + } + + /** + * Resolves promise once all stylesheets have been fetched and overridden + * @returns {Promise} + */ + async fix () { + await Promise.all(this.#fetchProms) + this.#fetchProms = [] + const failedToFix = this.failedToFix + this.failedToFix = false + return failedToFix + } + + /** + * Fetches stylesheet contents and overrides the target getters + * @param {*} target - The stylesheet object target - ex. document.styleSheets[0] + * @param {*} href - The asset href to fetch + * @returns {Promise} + */ + async #fetchAndOverride (target, href) { + const stylesheetContents = await originals.FETCH.bind(window)(href) + if (!stylesheetContents.ok) { + this.failedToFix = true + return + } + const stylesheetText = await stylesheetContents.text() + try { + const cssSheet = new CSSStyleSheet() + await cssSheet.replace(stylesheetText) + Object.defineProperty(target, 'cssRules', { + get () { return cssSheet.cssRules } + }) + Object.defineProperty(target, 'rules', { + get () { return cssSheet.rules } + }) + } catch (err) { + // cant make new dynamic stylesheets, browser likely doesn't support `.replace()`... + // this is appended in prep of forking rrweb + Object.defineProperty(target, 'cssText', { + get () { return stylesheetText } + }) + this.failedToFix = true + } + } +} + +export const stylesheetEvaluator = new StylesheetEvaluator() diff --git a/src/features/session_replay/shared/stylesheet-evaluator.test.js b/src/features/session_replay/shared/stylesheet-evaluator.test.js new file mode 100644 index 000000000..f56355b9a --- /dev/null +++ b/src/features/session_replay/shared/stylesheet-evaluator.test.js @@ -0,0 +1,95 @@ +import { stylesheetEvaluator } from './stylesheet-evaluator' + +let stylesheet + +describe('stylesheet-evaluator', (done) => { + beforeEach(async () => { + stylesheet = new CSSStyleSheet() + stylesheet.href = 'https://test.com' + + const globalScope = await import('../../../common/config/state/originals') + jest.replaceProperty(globalScope, 'originals', { + FETCH: jest.fn(() => + Promise.resolve({ + text: () => Promise.resolve('myCssText{width:1}') + }) + ) + }) + class CSSStyleSheetMock { + cssRules = {} + rules = {} + replace (txt) { + return new Promise((resolve) => { + this.cssRules = { txt } + this.rules = { txt } + resolve() + }) + } + } + global.CSSStyleSheet = CSSStyleSheetMock + }) + it('should evaluate stylesheets with cssRules as false', async () => { + prepStylesheet({ + get () { return 'success' } + }) + expect(stylesheetEvaluator.evaluate()).toEqual(0) + }) + + it('should evaluate stylesheets without cssRules as true', async () => { + prepStylesheet({ + get () { + throw new Error() + } + }) + expect(stylesheetEvaluator.evaluate()).toEqual(1) + }) + + it('should evaluate stylesheets once', async () => { + prepStylesheet({ + get () { + throw new Error() + } + }) + expect(stylesheetEvaluator.evaluate()).toEqual(1) + expect(stylesheetEvaluator.evaluate()).toEqual(0) + }) + + it('should execute fix single', async () => { + prepStylesheet({ + get () { return 'success' } + }) + stylesheetEvaluator.evaluate() + await stylesheetEvaluator.fix() + expect(document.styleSheets[0].cssRules).toEqual(stylesheet.cssRules) + }) + + it('should resolve as false if not browserScope', async () => { + jest.resetModules() + jest.doMock('../../../common/constants/runtime', () => ({ + globalScope: {}, + isBrowserScope: false + })) + const { stylesheetEvaluator } = await import('./stylesheet-evaluator') + prepStylesheet({ + get () { + throw new Error() + } + }) + expect(stylesheetEvaluator.evaluate()).toEqual(0) + }) +}) + +function prepStylesheet (cssRules) { + Object.defineProperty(stylesheet, 'cssRules', cssRules) + Object.defineProperty(document, 'styleSheets', { + value: { + 0: stylesheet, + [Symbol.iterator]: function * () { + for (let key in this) { + yield this[key] // yield [key, value] pair + } + } + }, + configurable: true + }) +} diff --git a/tests/assets/rrweb-instrumented.html b/tests/assets/rrweb-instrumented.html index 5c0e6b1b0..f7cbd83bc 100644 --- a/tests/assets/rrweb-instrumented.html +++ b/tests/assets/rrweb-instrumented.html @@ -18,18 +18,18 @@ top: 200px; } - + {init} {config} {loader} diff --git a/tests/assets/rrweb-invalid-stylesheet.html b/tests/assets/rrweb-invalid-stylesheet.html new file mode 100644 index 000000000..6543e4271 --- /dev/null +++ b/tests/assets/rrweb-invalid-stylesheet.html @@ -0,0 +1,56 @@ + + + + + RUM Unit Test + + + + {init} {config} {loader} + + + + this is a page that provides several types of elements with selectors that session_replay can interact with based on how it is configured +
+
+ + + + + + + + +
+ + + New Tab + + + diff --git a/tests/assets/rrweb-record.html b/tests/assets/rrweb-record.html index b170c6eb7..fe343e489 100644 --- a/tests/assets/rrweb-record.html +++ b/tests/assets/rrweb-record.html @@ -15,8 +15,7 @@ } - - + {init} {config} diff --git a/tests/specs/session-replay/helpers.js b/tests/specs/session-replay/helpers.js index 4025829aa..6922f6ed3 100644 --- a/tests/specs/session-replay/helpers.js +++ b/tests/specs/session-replay/helpers.js @@ -39,7 +39,8 @@ export function testExpectedReplay ({ data, session, hasMeta, hasSnapshot, hasEr agentVersion: expect.any(String), isFirstChunk: isFirstChunk || expect.any(Boolean), decompressedBytes: decompressedBytes || expect.any(Number), - 'rrweb.version': expect.any(String) + 'rrweb.version': expect.any(String), + inlinedAllStylesheets: expect.any(Boolean) }) expect(data.body).toEqual(expect.any(Array)) diff --git a/tests/specs/session-replay/mode.e2e.js b/tests/specs/session-replay/mode.e2e.js index bbe58e7de..64ddd298d 100644 --- a/tests/specs/session-replay/mode.e2e.js +++ b/tests/specs/session-replay/mode.e2e.js @@ -37,13 +37,11 @@ describe.withBrowsersMatching(notIE)('Session Replay Sample Mode Validation', () it('Full 0 Error 1 === ERROR', async () => { await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { sampling_rate: 0, error_sampling_rate: 100 } }))) .then(() => browser.waitForSessionReplayRecording()) - - await expect(getSR()).resolves.toEqual(expect.objectContaining({ - recording: true, - initialized: true, - events: expect.any(Array), - mode: 2 - })) + let sr = await getSR() + expect(sr.recording).toEqual(true) + expect(sr.initialized).toEqual(true) + expect(sr.events).toEqual(expect.any(Array)) + expect(sr.mode).toEqual(2) }) it('Full 0 Error 0 === OFF', async () => { @@ -88,11 +86,10 @@ describe.withBrowsersMatching(notIE)('Session Replay Sample Mode Validation', () await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { sampling_rate: 0, error_sampling_rate: 100 } }))) .then(() => browser.waitForFeatureAggregate('session_replay')).then(() => browser.pause(1000)) - await expect(getSR()).resolves.toEqual(expect.objectContaining({ - recording: true, - initialized: true, - mode: 2 - })) + let sr = await getSR() + expect(sr.recording).toEqual(true) + expect(sr.initialized).toEqual(true) + expect(sr.mode).toEqual(2) await Promise.all([ browser.execute(function () { @@ -102,11 +99,10 @@ describe.withBrowsersMatching(notIE)('Session Replay Sample Mode Validation', () await browser.pause(1000) - await expect(getSR()).resolves.toEqual(expect.objectContaining({ - recording: true, - initialized: true, - mode: 1 - })) + sr = await getSR() + expect(sr.recording).toEqual(true) + expect(sr.initialized).toEqual(true) + expect(sr.mode).toEqual(1) }) it('Record API called before page load does not start a replay (no entitlements yet)', async () => { diff --git a/tests/specs/session-replay/payload.e2e.js b/tests/specs/session-replay/payload.e2e.js index 1690f664c..206126b08 100644 --- a/tests/specs/session-replay/payload.e2e.js +++ b/tests/specs/session-replay/payload.e2e.js @@ -1,5 +1,5 @@ -import { config, testExpectedReplay } from './helpers' -import { notIE } from '../../../tools/browser-matcher/common-matchers.mjs' +import { config, decodeAttributes, testExpectedReplay } from './helpers' +import { notIE, notIOS, notSafari } from '../../../tools/browser-matcher/common-matchers.mjs' describe.withBrowsersMatching(notIE)('Session Replay Payload Validation', () => { beforeEach(async () => { @@ -98,4 +98,46 @@ describe.withBrowsersMatching(notIE)('Session Replay Payload Validation', () => testExpectedReplay({ data: harvestContents, session: localStorage.value, hasError: true, hasMeta: true, hasSnapshot: true, isFirstChunk: true }) }) + + /** + * auto-inlining broken stylesheets does not work in safari browsers < 16.3 + * current mitigation strategy is defined as informing customers to add `crossOrigin: anonymous` tags to cross-domain stylesheets + */ + it.withBrowsersMatching([notSafari, notIOS])('should place inlined css for cross origin stylesheets even if no crossOrigin tag', async () => { + await browser.url(await browser.testHandle.assetURL('rrweb-invalid-stylesheet.html', config())) + .then(() => browser.waitForFeatureAggregate('session_replay')) + + /** snapshot and mutation payloads */ + const { request: { body: snapshot1, query: snapshot1Query } } = await browser.testHandle.expectSessionReplaySnapshot(10000) + const snapshot1Nodes = snapshot1.filter(x => x.type === 2) + expect(decodeAttributes(snapshot1Query.attributes).inlinedAllStylesheets).toEqual(true) + snapshot1Nodes.forEach(snapshotNode => { + const htmlNode = snapshotNode.data.node.childNodes.find(x => x.tagName === 'html') + const headNode = htmlNode.childNodes.find(x => x.tagName === 'head') + const linkNodes = headNode.childNodes.filter(x => x.tagName === 'link') + linkNodes.forEach(linkNode => { + expect(!!linkNode.attributes._cssText).toEqual(true) + }) + }) + await browser.pause(5000) + /** Agent should generate a new snapshot after a new "invalid" stylesheet is injected */ + const [{ request: { body: snapshot2, query: snapshot2Query } }] = await Promise.all([ + browser.testHandle.expectSessionReplaySnapshot(10000), + browser.execute(function () { + var newelem = document.createElement('span') + newelem.innerHTML = 'this is some text' + document.body.appendChild(newelem) + }) + ]) + expect(decodeAttributes(snapshot2Query.attributes).inlinedAllStylesheets).toEqual(true) + const snapshot2Nodes = snapshot2.filter(x => x.type === 2) + snapshot2Nodes.forEach(snapshotNode => { + const htmlNode = snapshotNode.data.node.childNodes.find(x => x.tagName === 'html') + const headNode = htmlNode.childNodes.find(x => x.tagName === 'head') + const linkNodes = headNode.childNodes.filter(x => x.tagName === 'link') + linkNodes.forEach(linkNode => { + expect(!!linkNode.attributes._cssText).toEqual(true) + }) + }) + }) }) diff --git a/tests/specs/session-replay/rrweb-configuration.e2e.js b/tests/specs/session-replay/rrweb-configuration.e2e.js index 793fa1286..cfc438758 100644 --- a/tests/specs/session-replay/rrweb-configuration.e2e.js +++ b/tests/specs/session-replay/rrweb-configuration.e2e.js @@ -1,5 +1,5 @@ import { notIE } from '../../../tools/browser-matcher/common-matchers.mjs' -import { config } from './helpers' +import { config, decodeAttributes } from './helpers' describe.withBrowsersMatching(notIE)('RRWeb Configuration', () => { beforeEach(async () => { @@ -300,8 +300,9 @@ describe.withBrowsersMatching(notIE)('RRWeb Configuration', () => { await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config({ session_replay: { inline_stylesheet: false } }))) .then(() => browser.waitForFeatureAggregate('session_replay')) - const { request: { body } } = await browser.testHandle.expectBlob() + const { request: { body, query } } = await browser.testHandle.expectBlob() + expect(decodeAttributes(query.attributes).inlinedAllStylesheets).toEqual(false) const snapshotNode = body.find(x => x.type === 2) const htmlNode = snapshotNode.data.node.childNodes.find(x => x.tagName === 'html') const headNode = htmlNode.childNodes.find(x => x.tagName === 'head') @@ -313,8 +314,9 @@ describe.withBrowsersMatching(notIE)('RRWeb Configuration', () => { await browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', config())) .then(() => browser.waitForFeatureAggregate('session_replay')) - const { request: { body } } = await browser.testHandle.expectBlob() + const { request: { body, query } } = await browser.testHandle.expectBlob() + expect(decodeAttributes(query.attributes).inlinedAllStylesheets).toEqual(true) const snapshotNode = body.find(x => x.type === 2) const htmlNode = snapshotNode.data.node.childNodes.find(x => x.tagName === 'html') const headNode = htmlNode.childNodes.find(x => x.tagName === 'head') diff --git a/tools/testing-server/test-handle.js b/tools/testing-server/test-handle.js index 243c4e03a..8858687e3 100644 --- a/tools/testing-server/test-handle.js +++ b/tools/testing-server/test-handle.js @@ -16,7 +16,8 @@ const { testAjaxTimeSlicesRequest, testResourcesRequest, testInteractionEventsRequest, - testBlobRequest + testBlobRequest, + testSessionReplaySnapshotRequest } = require('./utils/expect-tests') /** @@ -402,4 +403,12 @@ module.exports = class TestHandle { expectTimeout }) } + + expectSessionReplaySnapshot (timeout, expectTimeout = false) { + return this.expect('bamServer', { + timeout, + test: testSessionReplaySnapshotRequest, + expectTimeout + }) + } } diff --git a/tools/testing-server/utils/expect-tests.js b/tools/testing-server/utils/expect-tests.js index a1efe4744..167af7e3b 100644 --- a/tools/testing-server/utils/expect-tests.js +++ b/tools/testing-server/utils/expect-tests.js @@ -281,3 +281,11 @@ module.exports.testBlobRequest = function testBlobRequest (request) { return false } } + +module.exports.testSessionReplaySnapshotRequest = function testSessionReplaySnapshotRequest (request) { + const url = new URL(request.url, 'resolve://') + if (url.pathname !== '/browser/blobs') return false + if (request?.query?.browser_monitoring_key !== this.testId) return false + if (!(request?.body && Array.isArray(request.body) && request.body.length)) return false + return !!(request.body.filter(x => x.type === 2).length) +} diff --git a/tools/wdio/plugins/testing-server/test-handle-connector.mjs b/tools/wdio/plugins/testing-server/test-handle-connector.mjs index 3ae5712a2..dcabc1437 100644 --- a/tools/wdio/plugins/testing-server/test-handle-connector.mjs +++ b/tools/wdio/plugins/testing-server/test-handle-connector.mjs @@ -6,7 +6,7 @@ import { testAjaxEventsRequest, testAjaxTimeSlicesRequest, testCustomMetricsRequest, testErrorsRequest, testEventsRequest, testInsRequest, testInteractionEventsRequest, testMetricsRequest, testResourcesRequest, testRumRequest, testSupportMetricsRequest, - testTimingEventsRequest, testBlobRequest + testTimingEventsRequest, testBlobRequest, testSessionReplaySnapshotRequest } from '../../../testing-server/utils/expect-tests.js' import defaultAssetQuery from './default-asset-query.mjs' import { getBrowserName, getBrowserVersion } from '../../../browsers-lists/utils.mjs' @@ -306,4 +306,12 @@ export class TestHandleConnector { expectTimeout }) } + + expectSessionReplaySnapshot (timeout, expectTimeout = false) { + return this.expect('bamServer', { + timeout, + test: testSessionReplaySnapshotRequest, + expectTimeout + }) + } }