diff --git a/.changeset/fluffy-planes-retire.md b/.changeset/fluffy-planes-retire.md new file mode 100644 index 0000000000..41e9601704 --- /dev/null +++ b/.changeset/fluffy-planes-retire.md @@ -0,0 +1,5 @@ +--- +'rrweb': patch +--- + +Feat: Add support for replaying :defined pseudo-class of custom elements diff --git a/.changeset/smart-ears-refuse.md b/.changeset/smart-ears-refuse.md new file mode 100644 index 0000000000..0aaaabcf0f --- /dev/null +++ b/.changeset/smart-ears-refuse.md @@ -0,0 +1,7 @@ +--- +'rrweb-snapshot': patch +--- + +Feat: Add 'isCustom' flag to serialized elements. + +This flag is used to indicate whether the element is a custom element or not. This is useful for replaying the :defined pseudo-class of custom elements. diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index a1ad8e81cf..fa618e607b 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,6 +1,11 @@ name: Tests -on: [push, pull_request] +on: + push: + branches: + - master + - release/** + pull_request: concurrency: ${{ github.workflow }}-${{ github.ref }} @@ -8,6 +13,8 @@ jobs: release: name: Tests runs-on: ubuntu-latest + # For now, we know this will fail, so we allow failures + continue-on-error: true steps: - name: Checkout Repo uses: actions/checkout@v3 diff --git a/guide.md b/guide.md index e2dbf0d23f..fd6267a19b 100644 --- a/guide.md +++ b/guide.md @@ -144,8 +144,11 @@ The parameter of `rrweb.record` accepts the following options. | blockSelector | null | Use a string to configure which selector should be blocked, refer to the [privacy](#privacy) chapter | | ignoreClass | 'rr-ignore' | Use a string or RegExp to configure which elements should be ignored, refer to the [privacy](#privacy) chapter | | ignoreCSSAttributes | null | array of CSS attributes that should be ignored | +| maskAllText | false | mask all text content as \* | | maskTextClass | 'rr-mask' | Use a string or RegExp to configure which elements should be masked, refer to the [privacy](#privacy) chapter | +| unmaskTextClass | null | Use a string or RegExp to configure which elements should be unmasked, refer to the [privacy](#privacy) chapter | | maskTextSelector | null | Use a string to configure which selector should be masked, refer to the [privacy](#privacy) chapter | +| unmaskTextSelector | null | Use a string to configure which selector should be unmasked, refer to the [privacy](#privacy) chapter | | maskAllInputs | false | mask all input content as \* | | maskInputOptions | { password: true } | mask some kinds of input \*
refer to the [list](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L77-L95) | | maskInputFn | - | customize mask input content recording logic | @@ -172,6 +175,7 @@ You may find some contents on the webpage which are not willing to be recorded, - An element with the class name `.rr-block` will not be recorded. Instead, it will replay as a placeholder with the same dimension. - An element with the class name `.rr-ignore` will not record its input events. - All text of elements with the class name `.rr-mask` and their children will be masked. +- All text of elements with the optional unmasking class name `unmaskTextClass` and their children will be unmasked, unless any child is marked with `.rr-mask`. - `input[type="password"]` will be masked by default. - Mask options to mask the content in input elements. diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 5cf52ebd38..c3b15babba 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -142,6 +142,18 @@ function buildNode( if (n.isSVG) { node = doc.createElementNS('http://www.w3.org/2000/svg', tagName); } else { + if ( + // If the tag name is a custom element name + n.isCustom && + // If the browser supports custom elements + doc.defaultView?.customElements && + // If the custom element hasn't been defined yet + !doc.defaultView.customElements.get(n.tagName) + ) + doc.defaultView.customElements.define( + n.tagName, + class extends doc.defaultView.HTMLElement {}, + ); node = doc.createElement(tagName); } /** diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 51f76f46f8..8789c197ea 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -293,31 +293,76 @@ export function _isBlockedElement( return false; } +function elementClassMatchesRegex(el: HTMLElement, regex: RegExp): boolean { + for (let eIndex = el.classList.length; eIndex--; ) { + const className = el.classList[eIndex]; + if (regex.test(className)) { + return true; + } + } + return false; +} + export function classMatchesRegex( node: Node | null, regex: RegExp, checkAncestors: boolean, ): boolean { if (!node) return false; - if (node.nodeType !== node.ELEMENT_NODE) { - if (!checkAncestors) return false; - return classMatchesRegex(node.parentNode, regex, checkAncestors); + if (checkAncestors) { + return ( + distanceToMatch(node, (node) => + elementClassMatchesRegex(node as HTMLElement, regex), + ) >= 0 + ); + } else if (node.nodeType === node.ELEMENT_NODE) { + return elementClassMatchesRegex(node as HTMLElement, regex); } + return false; +} - for (let eIndex = (node as HTMLElement).classList.length; eIndex--; ) { - const className = (node as HTMLElement).classList[eIndex]; - if (regex.test(className)) { - return true; +function distanceToMatch( + node: Node | null, + matchPredicate: (node: Node) => boolean, + limit = Infinity, + distance = 0, +): number { + if (!node) return -1; + if (node.nodeType !== node.ELEMENT_NODE) return -1; + if (distance > limit) return -1; + if (matchPredicate(node)) return distance; + return distanceToMatch(node.parentNode, matchPredicate, limit, distance + 1); +} + +function createMatchPredicate( + className: string | RegExp | null, + selector: string | null, +): (node: Node) => boolean { + return (node: Node) => { + const el = node as HTMLElement; + if (el === null) return false; + + if (className) { + if (typeof className === 'string') { + if (el.matches(`.${className}`)) return true; + } else if (elementClassMatchesRegex(el, className)) { + return true; + } } - } - if (!checkAncestors) return false; - return classMatchesRegex(node.parentNode, regex, checkAncestors); + + if (selector && el.matches(selector)) return true; + + return false; + }; } export function needMaskingText( node: Node, maskTextClass: string | RegExp, maskTextSelector: string | null, + unmaskTextClass: string | RegExp | null, + unmaskTextSelector: string | null, + maskAllText: boolean, ): boolean { try { const el: HTMLElement | null = @@ -326,21 +371,53 @@ export function needMaskingText( : node.parentElement; if (el === null) return false; - if (typeof maskTextClass === 'string') { - if (el.classList.contains(maskTextClass)) return true; - if (el.closest(`.${maskTextClass}`)) return true; + let maskDistance = -1; + let unmaskDistance = -1; + + if (maskAllText) { + unmaskDistance = distanceToMatch( + el, + createMatchPredicate(unmaskTextClass, unmaskTextSelector), + ); + + if (unmaskDistance < 0) { + return true; + } + + maskDistance = distanceToMatch( + el, + createMatchPredicate(maskTextClass, maskTextSelector), + unmaskDistance >= 0 ? unmaskDistance : Infinity, + ); } else { - if (classMatchesRegex(el, maskTextClass, true)) return true; - } + maskDistance = distanceToMatch( + el, + createMatchPredicate(maskTextClass, maskTextSelector), + ); + + if (maskDistance < 0) { + return false; + } - if (maskTextSelector) { - if (el.matches(maskTextSelector)) return true; - if (el.closest(maskTextSelector)) return true; + unmaskDistance = distanceToMatch( + el, + createMatchPredicate(unmaskTextClass, unmaskTextSelector), + maskDistance >= 0 ? maskDistance : Infinity, + ); } + + return maskDistance >= 0 + ? unmaskDistance >= 0 + ? maskDistance <= unmaskDistance + : true + : unmaskDistance >= 0 + ? false + : !!maskAllText; } catch (e) { // } - return false; + + return !!maskAllText; } // https://stackoverflow.com/a/36155560 @@ -434,8 +511,11 @@ function serializeNode( mirror: Mirror; blockClass: string | RegExp; blockSelector: string | null; + maskAllText: boolean; maskTextClass: string | RegExp; + unmaskTextClass: string | RegExp | null; maskTextSelector: string | null; + unmaskTextSelector: string | null; inlineStylesheet: boolean; maskInputOptions: MaskInputOptions; maskTextFn: MaskTextFn | undefined; @@ -455,8 +535,11 @@ function serializeNode( mirror, blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, inlineStylesheet, maskInputOptions = {}, maskTextFn, @@ -505,11 +588,19 @@ function serializeNode( keepIframeSrcFn, newlyAddedElement, rootId, + maskAllText, + maskTextClass, + unmaskTextClass, + maskTextSelector, + unmaskTextSelector, }); case n.TEXT_NODE: return serializeTextNode(n as Text, { + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, maskTextFn, rootId, }); @@ -539,13 +630,24 @@ function getRootId(doc: Document, mirror: Mirror): number | undefined { function serializeTextNode( n: Text, options: { + maskAllText: boolean; maskTextClass: string | RegExp; + unmaskTextClass: string | RegExp | null; maskTextSelector: string | null; + unmaskTextSelector: string | null; maskTextFn: MaskTextFn | undefined; rootId: number | undefined; }, ): serializedNode { - const { maskTextClass, maskTextSelector, maskTextFn, rootId } = options; + const { + maskAllText, + maskTextClass, + unmaskTextClass, + maskTextSelector, + unmaskTextSelector, + maskTextFn, + rootId, + } = options; // The parent node may not be a html element which has a tagName attribute. // So just let it be undefined which is ok in this use case. const parentTagName = n.parentNode && (n.parentNode as HTMLElement).tagName; @@ -580,7 +682,14 @@ function serializeTextNode( !isStyle && !isScript && textContent && - needMaskingText(n, maskTextClass, maskTextSelector) + needMaskingText( + n, + maskTextClass, + maskTextSelector, + unmaskTextClass, + unmaskTextSelector, + maskAllText, + ) ) { textContent = maskTextFn ? maskTextFn(textContent) @@ -613,6 +722,11 @@ function serializeElementNode( */ newlyAddedElement?: boolean; rootId: number | undefined; + maskAllText: boolean; + maskTextClass: string | RegExp; + unmaskTextClass: string | RegExp | null; + maskTextSelector: string | null; + unmaskTextSelector: string | null; }, ): serializedNode | false { const { @@ -628,6 +742,11 @@ function serializeElementNode( keepIframeSrcFn, newlyAddedElement = false, rootId, + maskAllText, + maskTextClass, + unmaskTextClass, + maskTextSelector, + unmaskTextSelector, } = options; const needBlock = _isBlockedElement(n, blockClass, blockSelector); const tagName = getValidTagName(n); @@ -685,6 +804,15 @@ function serializeElementNode( value ) { const type = getInputType(n); + const forceMask = needMaskingText( + n, + maskTextClass, + maskTextSelector, + unmaskTextClass, + unmaskTextSelector, + maskAllText, + ); + attributes.value = maskInputValue({ element: n, type, @@ -692,6 +820,7 @@ function serializeElementNode( value, maskInputOptions, maskInputFn, + forceMask, }); } else if (checked) { attributes.checked = checked; @@ -809,6 +938,13 @@ function serializeElementNode( delete attributes.src; // prevent auto loading } + let isCustomElement: true | undefined; + try { + if (customElements.get(tagName)) isCustomElement = true; + } catch (e) { + // In case old browsers don't support customElements + } + return { type: NodeType.Element, tagName, @@ -817,6 +953,7 @@ function serializeElementNode( isSVG: isSVGElement(n as Element) || undefined, needBlock, rootId, + isCustom: isCustomElement, }; } @@ -930,11 +1067,14 @@ export function serializeNodeWithId( blockClass: string | RegExp; blockSelector: string | null; maskTextClass: string | RegExp; + unmaskTextClass: string | RegExp | null; maskTextSelector: string | null; + unmaskTextSelector: string | null; skipChild: boolean; inlineStylesheet: boolean; newlyAddedElement?: boolean; maskInputOptions?: MaskInputOptions; + maskAllText: boolean; maskTextFn: MaskTextFn | undefined; maskInputFn: MaskInputFn | undefined; slimDOMOptions: SlimDOMOptions; @@ -961,8 +1101,11 @@ export function serializeNodeWithId( mirror, blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, skipChild = false, inlineStylesheet = true, maskInputOptions = {}, @@ -986,8 +1129,11 @@ export function serializeNodeWithId( mirror, blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, inlineStylesheet, maskInputOptions, maskTextFn, @@ -1058,8 +1204,11 @@ export function serializeNodeWithId( mirror, blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, skipChild, inlineStylesheet, maskInputOptions, @@ -1118,8 +1267,11 @@ export function serializeNodeWithId( mirror, blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, skipChild: false, inlineStylesheet, maskInputOptions, @@ -1165,8 +1317,11 @@ export function serializeNodeWithId( mirror, blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, skipChild: false, inlineStylesheet, maskInputOptions, @@ -1206,12 +1361,15 @@ function snapshot( mirror?: Mirror; blockClass?: string | RegExp; blockSelector?: string | null; + maskAllText?: boolean; maskTextClass?: string | RegExp; + unmaskTextClass?: string | RegExp | null; maskTextSelector?: string | null; + unmaskTextSelector?: string | null; inlineStylesheet?: boolean; maskAllInputs?: boolean | MaskInputOptions; maskTextFn?: MaskTextFn; - maskInputFn?: MaskTextFn; + maskInputFn?: MaskInputFn; slimDOM?: 'all' | boolean | SlimDOMOptions; dataURLOptions?: DataURLOptions; inlineImages?: boolean; @@ -1235,8 +1393,11 @@ function snapshot( mirror = new Mirror(), blockClass = 'rr-block', blockSelector = null, + maskAllText = false, maskTextClass = 'rr-mask', + unmaskTextClass = null, maskTextSelector = null, + unmaskTextSelector = null, inlineStylesheet = true, inlineImages = false, recordCanvas = false, @@ -1301,8 +1462,11 @@ function snapshot( mirror, blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, skipChild: false, inlineStylesheet, maskInputOptions, diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index 9edb4dd6d4..089afadd40 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -38,6 +38,8 @@ export type elementNode = { childNodes: serializedNodeWithId[]; isSVG?: true; needBlock?: boolean; + // This is a custom element or not. + isCustom?: true; }; export type textNode = { @@ -67,6 +69,7 @@ export type serializedNode = ( rootId?: number; isShadowHost?: boolean; isShadow?: boolean; + isCustom?: boolean; }; export type serializedNodeWithId = serializedNode & { id: number }; diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index b124680b57..49e2d70d94 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -160,6 +160,7 @@ export function maskInputValue({ type, value, maskInputFn, + forceMask, }: { element: HTMLElement; maskInputOptions: MaskInputOptions; @@ -167,13 +168,15 @@ export function maskInputValue({ type: string | null; value: string | null; maskInputFn?: MaskInputFn; + forceMask?: boolean; }): string { let text = value || ''; const actualType = type && toLowerCase(type); if ( maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] || - (actualType && maskInputOptions[actualType as keyof MaskInputOptions]) + (actualType && maskInputOptions[actualType as keyof MaskInputOptions]) || + forceMask ) { if (maskInputFn) { text = maskInputFn(text, element); diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap index 529a51eeff..9488069129 100644 --- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap @@ -246,7 +246,10 @@ exports[`integration tests [html file]: form-fields.html 1`] = ` + + + + + + + + diff --git a/packages/rrweb/test/html/mask-text.html b/packages/rrweb/test/html/mask-text.html index 2abaaaa511..7610310985 100644 --- a/packages/rrweb/test/html/mask-text.html +++ b/packages/rrweb/test/html/mask-text.html @@ -16,5 +16,15 @@
mask3
+
+ mask4 +
+
+ mask5 +
+ +
+ + diff --git a/packages/rrweb/test/html/unmask-text.html b/packages/rrweb/test/html/unmask-text.html new file mode 100644 index 0000000000..a71dc04205 --- /dev/null +++ b/packages/rrweb/test/html/unmask-text.html @@ -0,0 +1,23 @@ + + + + + + + Unmask text + + +
unmask1 +
+ mask1 +
+
+
+
+
mask2
+
unmask2
+
+ +
+ + diff --git a/packages/rrweb/test/integration-sentry.test.ts b/packages/rrweb/test/integration-sentry.test.ts new file mode 100644 index 0000000000..982aa9726c --- /dev/null +++ b/packages/rrweb/test/integration-sentry.test.ts @@ -0,0 +1,427 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type * as puppeteer from 'puppeteer'; +import { + assertSnapshot, + startServer, + getServerURL, + launchPuppeteer, + replaceLast, + generateRecordSnippet, + ISuite, +} from './utils'; +import type { recordOptions } from '../src/types'; +import { eventWithTime, EventType, IncrementalSource } from '@rrweb/types'; + +/** + * Used to filter scroll events out of snapshots as they are flakey + */ +function isNotScroll(snapshot: eventWithTime) { + return !( + snapshot.type === EventType.IncrementalSnapshot && + snapshot.data.source === IncrementalSource.Scroll + ); +} + +describe('record integration tests', function (this: ISuite) { + jest.setTimeout(10_000); + + const getHtml = ( + fileName: string, + options: recordOptions = {}, + ): string => { + const filePath = path.resolve(__dirname, `./html/${fileName}`); + const html = fs.readFileSync(filePath, 'utf8'); + return replaceLast( + html, + '', + ` + + + `, + ); + }; + + let server: ISuite['server']; + let serverURL: string; + let code: ISuite['code']; + let browser: ISuite['browser']; + + beforeAll(async () => { + server = await startServer(); + serverURL = getServerURL(server); + browser = await launchPuppeteer(); + + const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js'); + const pluginsCode = [ + path.resolve(__dirname, '../dist/plugins/console-record.min.js'), + ] + .map((path) => fs.readFileSync(path, 'utf8')) + .join(); + code = fs.readFileSync(bundlePath, 'utf8') + pluginsCode; + }); + + afterAll(async () => { + await browser.close(); + server.close(); + }); + + it.skip('can configure onMutation', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + + await page.setContent( + getHtml.call(this, 'mutation-observer.html', { + // XXX(sentry) + // onMutation: `(mutations) => { window.lastMutationsLength = mutations.length; return mutations.length < 500 }`, + }), + ); + + await page.evaluate(() => { + const ul = document.querySelector('ul') as HTMLUListElement; + + for (let i = 0; i < 2000; i++) { + const li = document.createElement('li'); + ul.appendChild(li); + const p = document.querySelector('p') as HTMLParagraphElement; + p.appendChild(document.createElement('span')); + } + }); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + + const lastMutationsLength = await page.evaluate( + 'window.lastMutationsLength', + ); + expect(lastMutationsLength).toBe(4000); + }); + + it('should not record input values on selectively masked elements when maskAllInputs is disabled', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'form-masked.html', { + maskAllInputs: false, + maskTextSelector: '.rr-mask', + }), + ); + + await page.type('input[type="text"]', 'test'); + await page.click('input[type="radio"]'); + await page.click('input[type="checkbox"]'); + await page.type('input[type="password"]', 'password'); + await page.type('textarea', 'textarea test'); + await page.select('select', '1'); + await page.type('#empty', 'test'); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + + it('correctly masks & unmasks attribute values', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'attributes-mask.html', { + maskAllText: true, + unmaskTextSelector: '.rr-unmask', + }), + ); + + // Change attributes, should still be masked + await page.evaluate(() => { + document + .querySelectorAll('body [title]') + .forEach((el) => el.setAttribute('title', 'new title')); + document + .querySelectorAll('body [aria-label]') + .forEach((el) => el.setAttribute('aria-label', 'new aria label')); + document + .querySelectorAll('body [placeholder]') + .forEach((el) => el.setAttribute('placeholder', 'new placeholder')); + document + .querySelectorAll('input[type="button"],input[type="submit"]') + .forEach((el) => el.setAttribute('value', 'new value')); + }); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + + it('should record input values if dynamically added and maskAllInputs is false', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'empty.html', { maskAllInputs: false }), + ); + + await page.evaluate(() => { + const el = document.createElement('input'); + el.id = 'input'; + el.value = 'input should not be masked'; + + const nextElement = document.querySelector('#one')!; + nextElement.parentNode!.insertBefore(el, nextElement); + }); + + await page.type('#input', 'moo'); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots.filter(isNotScroll)); + }); + + it('should record textarea values if dynamically added and maskAllInputs is false', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'empty.html', { maskAllInputs: false }), + ); + + await page.evaluate(() => { + const el = document.createElement('textarea'); + el.id = 'textarea'; + el.innerText = `textarea should not be masked +`; + + const nextElement = document.querySelector('#one')!; + nextElement.parentNode!.insertBefore(el, nextElement); + }); + + await page.type('#textarea', 'moo'); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots.filter(isNotScroll)); + }); + + it('should record input values if dynamically added, maskAllInputs is false, and mask selector is used', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'empty.html', { + maskAllInputs: false, + maskTextSelector: '.rr-mask', + }), + ); + + await page.evaluate(() => { + const el = document.createElement('input'); + el.id = 'input-masked'; + el.className = 'rr-mask'; + el.value = 'input should be masked'; + + const nextElement = document.querySelector('#one')!; + nextElement.parentNode!.insertBefore(el, nextElement); + }); + + await page.type('#input-masked', 'moo'); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots.filter(isNotScroll)); + }); + + it('should not record textarea values if dynamically added and maskAllInputs is true', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'empty.html', { maskAllInputs: true }), + ); + + await page.evaluate(() => { + const el = document.createElement('textarea'); + el.id = 'textarea'; + el.innerText = `textarea should be masked +`; + + const nextElement = document.querySelector('#one')!; + nextElement.parentNode!.insertBefore(el, nextElement); + }); + + await page.type('#textarea', 'moo'); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots.filter(isNotScroll)); + }); + + it('should record input values if dynamically added, maskAllInputs is true, and unmask selector is used', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'empty.html', { + maskAllInputs: true, + unmaskTextSelector: '.rr-unmask', + }), + ); + + await page.evaluate(() => { + const el = document.createElement('input'); + el.id = 'input-unmasked'; + el.className = 'rr-unmask'; + el.value = 'input should be unmasked'; + + const nextElement = document.querySelector('#one')!; + nextElement.parentNode!.insertBefore(el, nextElement); + }); + + await page.type('#input-unmasked', 'moo'); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots.filter(isNotScroll)); + }); + + it('should always mask value attribute of passwords', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'password.html', { + maskInputOptions: {}, + }), + ); + + await page.type('#password', 'secr3t'); + + // Change type to text (simulate "show password") + await page.click('#show-password'); + await page.type('#password', 'XY'); + await page.click('#show-password'); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + + it('should mask text in form elements', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'form.html', { + maskAllText: true, + }), + ); + + // Ensure also masked when we change stuff + await page.evaluate(() => { + document + .querySelector('input[type="submit"]') + ?.setAttribute('value', 'new value'); + }); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + + it('should not record blocked elements from blockSelector, when dynamically added', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'block.html', { + blockSelector: 'video', + }), + ); + + await page.evaluate(() => { + const el2 = document.createElement('video'); + el2.className = 'rr-block'; + el2.style.width = '100px'; + el2.style.height = '100px'; + const source2 = document.createElement('source'); + source2.src = 'file:///foo.mp4'; + // These aren't valid, but doing this for testing + source2.style.width = '100px'; + source2.style.height = '100px'; + el2.appendChild(source2); + + const el = document.createElement('video'); + el.style.width = '100px'; + el.style.height = '100px'; + const source = document.createElement('source'); + source.src = 'file:///foo.mp4'; + // These aren't valid, but doing this for testing + source.style.width = '100px'; + source.style.height = '100px'; + el.appendChild(source); + + const nextElement = document.querySelector('.rr-block')!; + nextElement.parentNode!.insertBefore(el, nextElement); + nextElement.parentNode!.insertBefore(el2, nextElement); + }); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + + it('should only record unblocked elements', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'block.html', { + blockSelector: 'img,svg', + // XXX(sentry) + // unblockSelector: '.rr-unblock', + }), + ); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + + it('should mask only inputs', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'mask-text.html', { + maskAllInputs: true, + maskAllText: false, + }), + ); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + + it('should mask all text (except unmaskTextSelector), using maskAllText ', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'mask-text.html', { + maskTextClass: 'none', + maskAllText: true, + unmaskTextSelector: '.rr-unmask', + }), + ); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); +}); diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 39e32dbcd6..a0eae0f3df 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -12,7 +12,12 @@ import { ISuite, } from './utils'; import type { recordOptions } from '../src/types'; -import { eventWithTime, EventType, RecordPlugin } from '@rrweb/types'; +import { + eventWithTime, + EventType, + RecordPlugin, + IncrementalSource, +} from '@rrweb/types'; import { visitSnapshot, NodeType } from 'rrweb-snapshot'; describe('record integration tests', function (this: ISuite) { @@ -122,7 +127,7 @@ describe('record integration tests', function (this: ISuite) { assertSnapshot(snapshots); }); - it('can record character data muatations', async () => { + it('can record character data mutations', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); await page.setContent(getHtml.call(this, 'mutation-observer.html')); @@ -167,7 +172,13 @@ describe('record integration tests', function (this: ISuite) { it('handles null attribute values', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); - await page.setContent(getHtml.call(this, 'mutation-observer.html', {})); + await page.setContent( + getHtml.call(this, 'mutation-observer.html', { + maskAllInputs: true, + // XXX(sentry) + maskAllText: true, + }), + ); await page.evaluate(() => { const li = document.createElement('li'); @@ -262,7 +273,11 @@ describe('record integration tests', function (this: ISuite) { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); await page.setContent( - getHtml.call(this, 'form.html', { maskAllInputs: true }), + getHtml.call(this, 'form.html', { + maskAllInputs: true, + // XXX(sentry) + unmaskTextSelector: '.rr-unmask', + }), ); await page.type('input[type="text"]', 'test'); @@ -271,6 +286,7 @@ describe('record integration tests', function (this: ISuite) { await page.type('input[type="password"]', 'password'); await page.type('textarea', 'textarea test'); await page.select('select', '1'); + await page.type('#empty', 'test'); const snapshots = (await page.evaluate( 'window.snapshots', @@ -286,11 +302,35 @@ describe('record integration tests', function (this: ISuite) { maskInputOptions: { text: false, textarea: false, - password: true, + color: true, }, }), ); + await page.type('input[type="text"]', 'test'); + await page.type('input[type="color"]', '#FF0000'); + await page.click('input[type="radio"]'); + await page.click('input[type="checkbox"]'); + await page.type('textarea', 'textarea test'); + await page.type('input[type="password"]', 'password'); + await page.select('select', '1'); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + + it('can use maskTextSelector to configure which inputs should be masked', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'form.html', { + maskTextSelector: 'input[type="text"],textarea', + maskInputFn: () => '*'.repeat(10), + }), + ); + await page.type('input[type="text"]', 'test'); await page.click('input[type="radio"]'); await page.click('input[type="checkbox"]'); @@ -1141,6 +1181,31 @@ describe('record integration tests', function (this: ISuite) { assertSnapshot(snapshots); }); + it('can selectively unmask parts of the page', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'unmask-text.html', { + maskAllText: true, + maskTextSelector: '[data-masking="true"]', + unmaskTextSelector: '[data-masking="false"]', + }), + ); + + await page.evaluate(() => { + const li = document.createElement('li'); + const ul = document.querySelector('ul') as HTMLUListElement; + li.className = 'rr-mask'; + ul.appendChild(li); + li.innerText = 'new list item'; + }); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + it('should record after DOMContentLoaded event', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); diff --git a/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap b/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap index 48d4bdb33a..a82228fe9f 100644 --- a/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap +++ b/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap @@ -1310,9 +1310,8 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"type\\": 2, \\"tagName\\": \\"input\\", \\"attributes\\": { - \\"type\\": \\"radio\\", - \\"name\\": \\"toggle\\", - \\"value\\": \\"on\\" + \\"type\\": \\"color\\", + \\"value\\": \\"#000000\\" }, \\"childNodes\\": [], \\"rootId\\": 11, @@ -1350,9 +1349,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"tagName\\": \\"input\\", \\"attributes\\": { \\"type\\": \\"radio\\", - \\"name\\": \\"toggle\\", - \\"value\\": \\"off\\", - \\"checked\\": true + \\"name\\": \\"toggle\\" }, \\"childNodes\\": [], \\"rootId\\": 11, @@ -1377,9 +1374,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = { \\"type\\": 2, \\"tagName\\": \\"label\\", - \\"attributes\\": { - \\"for\\": \\"checkbox\\" - }, + \\"attributes\\": {}, \\"childNodes\\": [ { \\"type\\": 3, @@ -1391,7 +1386,9 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"type\\": 2, \\"tagName\\": \\"input\\", \\"attributes\\": { - \\"type\\": \\"checkbox\\" + \\"type\\": \\"radio\\", + \\"name\\": \\"toggle\\", + \\"value\\": \\"radio-on\\" }, \\"childNodes\\": [], \\"rootId\\": 11, @@ -1413,6 +1410,124 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"rootId\\": 11, \\"id\\": 51 }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 53 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"radio\\", + \\"name\\": \\"toggle\\", + \\"value\\": \\"radio-off\\", + \\"checked\\": true + }, + \\"childNodes\\": [], + \\"rootId\\": 11, + \\"id\\": 54 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 55 + } + ], + \\"rootId\\": 11, + \\"id\\": 52 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 56 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": { + \\"for\\": \\"checkbox\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 58 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"checkbox\\", + \\"checked\\": true + }, + \\"childNodes\\": [], + \\"rootId\\": 11, + \\"id\\": 59 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 60 + } + ], + \\"rootId\\": 11, + \\"id\\": 57 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 61 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 63 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"checkbox\\", + \\"value\\": \\"check-val\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 11, + \\"id\\": 64 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 65 + } + ], + \\"rootId\\": 11, + \\"id\\": 62 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 66 + }, { \\"type\\": 2, \\"tagName\\": \\"label\\", @@ -1424,7 +1539,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 53 + \\"id\\": 68 }, { \\"type\\": 2, @@ -1438,23 +1553,23 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = }, \\"childNodes\\": [], \\"rootId\\": 11, - \\"id\\": 54 + \\"id\\": 69 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 55 + \\"id\\": 70 } ], \\"rootId\\": 11, - \\"id\\": 52 + \\"id\\": 67 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 56 + \\"id\\": 71 }, { \\"type\\": 2, @@ -1467,7 +1582,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 58 + \\"id\\": 73 }, { \\"type\\": 2, @@ -1482,7 +1597,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 60 + \\"id\\": 75 }, { \\"type\\": 2, @@ -1494,19 +1609,19 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"childNodes\\": [ { \\"type\\": 3, - \\"textContent\\": \\"1\\", + \\"textContent\\": \\"Option A\\", \\"rootId\\": 11, - \\"id\\": 62 + \\"id\\": 77 } ], \\"rootId\\": 11, - \\"id\\": 61 + \\"id\\": 76 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 63 + \\"id\\": 78 }, { \\"type\\": 2, @@ -1517,39 +1632,39 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"childNodes\\": [ { \\"type\\": 3, - \\"textContent\\": \\"2\\", + \\"textContent\\": \\"Option BB\\", \\"rootId\\": 11, - \\"id\\": 65 + \\"id\\": 80 } ], \\"rootId\\": 11, - \\"id\\": 64 + \\"id\\": 79 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 66 + \\"id\\": 81 } ], \\"rootId\\": 11, - \\"id\\": 59 + \\"id\\": 74 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 67 + \\"id\\": 82 } ], \\"rootId\\": 11, - \\"id\\": 57 + \\"id\\": 72 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 68 + \\"id\\": 83 }, { \\"type\\": 2, @@ -1562,7 +1677,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 70 + \\"id\\": 85 }, { \\"type\\": 2, @@ -1572,23 +1687,119 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = }, \\"childNodes\\": [], \\"rootId\\": 11, - \\"id\\": 71 + \\"id\\": 86 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 87 + } + ], + \\"rootId\\": 11, + \\"id\\": 84 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 88 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": { + \\"for\\": \\"empty\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 90 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"id\\": \\"empty\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 11, + \\"id\\": 91 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 92 + } + ], + \\"rootId\\": 11, + \\"id\\": 89 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 93 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": { + \\"for\\": \\"unmask\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 95 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\", + \\"class\\": \\"rr-unmask\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 11, + \\"id\\": 96 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 72 + \\"id\\": 97 } ], \\"rootId\\": 11, - \\"id\\": 69 + \\"id\\": 94 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 98 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"submit\\", + \\"value\\": \\"Submit form\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 11, + \\"id\\": 99 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 73 + \\"id\\": 100 } ], \\"rootId\\": 11, @@ -1598,7 +1809,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"type\\": 3, \\"textContent\\": \\"\\\\n \\\\n\\\\n\\", \\"rootId\\": 11, - \\"id\\": 74 + \\"id\\": 101 } ], \\"rootId\\": 11, @@ -1668,7 +1879,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"data\\": { \\"source\\": 2, \\"type\\": 1, - \\"id\\": 39 + \\"id\\": 44 } }, { @@ -1684,7 +1895,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"data\\": { \\"source\\": 2, \\"type\\": 5, - \\"id\\": 39 + \\"id\\": 44 } }, { @@ -1692,7 +1903,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"data\\": { \\"source\\": 2, \\"type\\": 0, - \\"id\\": 39 + \\"id\\": 44 } }, { @@ -1700,7 +1911,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"data\\": { \\"source\\": 2, \\"type\\": 2, - \\"id\\": 39, + \\"id\\": 44, \\"pointerType\\": 0 } }, @@ -1710,16 +1921,25 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"on\\", \\"isChecked\\": true, - \\"id\\": 39 + \\"id\\": 44 } }, { \\"type\\": 3, \\"data\\": { \\"source\\": 5, - \\"text\\": \\"off\\", + \\"text\\": \\"radio-on\\", \\"isChecked\\": false, - \\"id\\": 44 + \\"id\\": 49 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 5, + \\"text\\": \\"radio-off\\", + \\"isChecked\\": false, + \\"id\\": 54 } }, { @@ -1727,7 +1947,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"data\\": { \\"source\\": 2, \\"type\\": 1, - \\"id\\": 49 + \\"id\\": 59 } }, { @@ -1735,7 +1955,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"data\\": { \\"source\\": 2, \\"type\\": 6, - \\"id\\": 39 + \\"id\\": 44 } }, { @@ -1743,7 +1963,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"data\\": { \\"source\\": 2, \\"type\\": 5, - \\"id\\": 49 + \\"id\\": 59 } }, { @@ -1751,7 +1971,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"data\\": { \\"source\\": 2, \\"type\\": 0, - \\"id\\": 49 + \\"id\\": 59 } }, { @@ -1759,7 +1979,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"data\\": { \\"source\\": 2, \\"type\\": 2, - \\"id\\": 49, + \\"id\\": 59, \\"pointerType\\": 0 } }, @@ -1768,8 +1988,8 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"data\\": { \\"source\\": 5, \\"text\\": \\"on\\", - \\"isChecked\\": true, - \\"id\\": 49 + \\"isChecked\\": false, + \\"id\\": 59 } }, { @@ -1777,7 +1997,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"data\\": { \\"source\\": 2, \\"type\\": 6, - \\"id\\": 49 + \\"id\\": 59 } }, { @@ -1785,7 +2005,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"data\\": { \\"source\\": 2, \\"type\\": 5, - \\"id\\": 71 + \\"id\\": 86 } }, { @@ -1794,7 +2014,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"*\\", \\"isChecked\\": false, - \\"id\\": 71 + \\"id\\": 86 } }, { @@ -1803,7 +2023,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"**\\", \\"isChecked\\": false, - \\"id\\": 71 + \\"id\\": 86 } }, { @@ -1812,7 +2032,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"***\\", \\"isChecked\\": false, - \\"id\\": 71 + \\"id\\": 86 } }, { @@ -1821,7 +2041,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"****\\", \\"isChecked\\": false, - \\"id\\": 71 + \\"id\\": 86 } }, { @@ -1830,7 +2050,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"*****\\", \\"isChecked\\": false, - \\"id\\": 71 + \\"id\\": 86 } }, { @@ -1839,7 +2059,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"******\\", \\"isChecked\\": false, - \\"id\\": 71 + \\"id\\": 86 } }, { @@ -1848,7 +2068,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"*******\\", \\"isChecked\\": false, - \\"id\\": 71 + \\"id\\": 86 } }, { @@ -1857,7 +2077,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"********\\", \\"isChecked\\": false, - \\"id\\": 71 + \\"id\\": 86 } }, { @@ -1865,7 +2085,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"data\\": { \\"source\\": 2, \\"type\\": 6, - \\"id\\": 71 + \\"id\\": 86 } }, { @@ -1873,7 +2093,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"data\\": { \\"source\\": 2, \\"type\\": 5, - \\"id\\": 54 + \\"id\\": 69 } }, { @@ -1882,7 +2102,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"t\\", \\"isChecked\\": false, - \\"id\\": 54 + \\"id\\": 69 } }, { @@ -1891,7 +2111,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"te\\", \\"isChecked\\": false, - \\"id\\": 54 + \\"id\\": 69 } }, { @@ -1900,7 +2120,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"tex\\", \\"isChecked\\": false, - \\"id\\": 54 + \\"id\\": 69 } }, { @@ -1909,7 +2129,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"text\\", \\"isChecked\\": false, - \\"id\\": 54 + \\"id\\": 69 } }, { @@ -1918,7 +2138,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"texta\\", \\"isChecked\\": false, - \\"id\\": 54 + \\"id\\": 69 } }, { @@ -1927,7 +2147,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"textar\\", \\"isChecked\\": false, - \\"id\\": 54 + \\"id\\": 69 } }, { @@ -1936,7 +2156,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"textare\\", \\"isChecked\\": false, - \\"id\\": 54 + \\"id\\": 69 } }, { @@ -1945,7 +2165,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"textarea\\", \\"isChecked\\": false, - \\"id\\": 54 + \\"id\\": 69 } }, { @@ -1954,7 +2174,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"textarea \\", \\"isChecked\\": false, - \\"id\\": 54 + \\"id\\": 69 } }, { @@ -1963,7 +2183,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"textarea t\\", \\"isChecked\\": false, - \\"id\\": 54 + \\"id\\": 69 } }, { @@ -1972,7 +2192,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"textarea te\\", \\"isChecked\\": false, - \\"id\\": 54 + \\"id\\": 69 } }, { @@ -1981,7 +2201,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"textarea tes\\", \\"isChecked\\": false, - \\"id\\": 54 + \\"id\\": 69 } }, { @@ -1990,7 +2210,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"textarea test\\", \\"isChecked\\": false, - \\"id\\": 54 + \\"id\\": 69 } }, { @@ -1999,7 +2219,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] = \\"source\\": 5, \\"text\\": \\"1\\", \\"isChecked\\": false, - \\"id\\": 59 + \\"id\\": 74 } } ]" @@ -2307,9 +2527,8 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] = \\"type\\": 2, \\"tagName\\": \\"input\\", \\"attributes\\": { - \\"type\\": \\"radio\\", - \\"name\\": \\"toggle\\", - \\"value\\": \\"on\\" + \\"type\\": \\"color\\", + \\"value\\": \\"#000000\\" }, \\"childNodes\\": [], \\"rootId\\": 11, @@ -2347,9 +2566,7 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] = \\"tagName\\": \\"input\\", \\"attributes\\": { \\"type\\": \\"radio\\", - \\"name\\": \\"toggle\\", - \\"value\\": \\"off\\", - \\"checked\\": true + \\"name\\": \\"toggle\\" }, \\"childNodes\\": [], \\"rootId\\": 11, @@ -2374,9 +2591,7 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] = { \\"type\\": 2, \\"tagName\\": \\"label\\", - \\"attributes\\": { - \\"for\\": \\"checkbox\\" - }, + \\"attributes\\": {}, \\"childNodes\\": [ { \\"type\\": 3, @@ -2388,7 +2603,9 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] = \\"type\\": 2, \\"tagName\\": \\"input\\", \\"attributes\\": { - \\"type\\": \\"checkbox\\" + \\"type\\": \\"radio\\", + \\"name\\": \\"toggle\\", + \\"value\\": \\"radio-on\\" }, \\"childNodes\\": [], \\"rootId\\": 11, @@ -2410,6 +2627,124 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] = \\"rootId\\": 11, \\"id\\": 51 }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 53 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"radio\\", + \\"name\\": \\"toggle\\", + \\"value\\": \\"radio-off\\", + \\"checked\\": true + }, + \\"childNodes\\": [], + \\"rootId\\": 11, + \\"id\\": 54 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 55 + } + ], + \\"rootId\\": 11, + \\"id\\": 52 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 56 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": { + \\"for\\": \\"checkbox\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 58 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"checkbox\\", + \\"checked\\": true + }, + \\"childNodes\\": [], + \\"rootId\\": 11, + \\"id\\": 59 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 60 + } + ], + \\"rootId\\": 11, + \\"id\\": 57 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 61 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 63 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"checkbox\\", + \\"value\\": \\"check-val\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 11, + \\"id\\": 64 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 65 + } + ], + \\"rootId\\": 11, + \\"id\\": 62 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 66 + }, { \\"type\\": 2, \\"tagName\\": \\"label\\", @@ -2421,7 +2756,7 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] = \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 53 + \\"id\\": 68 }, { \\"type\\": 2, @@ -2435,23 +2770,23 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] = }, \\"childNodes\\": [], \\"rootId\\": 11, - \\"id\\": 54 + \\"id\\": 69 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 55 + \\"id\\": 70 } ], \\"rootId\\": 11, - \\"id\\": 52 + \\"id\\": 67 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 56 + \\"id\\": 71 }, { \\"type\\": 2, @@ -2464,7 +2799,7 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] = \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 58 + \\"id\\": 73 }, { \\"type\\": 2, @@ -2479,7 +2814,7 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] = \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 60 + \\"id\\": 75 }, { \\"type\\": 2, @@ -2491,19 +2826,19 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] = \\"childNodes\\": [ { \\"type\\": 3, - \\"textContent\\": \\"1\\", + \\"textContent\\": \\"Option A\\", \\"rootId\\": 11, - \\"id\\": 62 + \\"id\\": 77 } ], \\"rootId\\": 11, - \\"id\\": 61 + \\"id\\": 76 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 63 + \\"id\\": 78 }, { \\"type\\": 2, @@ -2514,39 +2849,39 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] = \\"childNodes\\": [ { \\"type\\": 3, - \\"textContent\\": \\"2\\", + \\"textContent\\": \\"Option BB\\", \\"rootId\\": 11, - \\"id\\": 65 + \\"id\\": 80 } ], \\"rootId\\": 11, - \\"id\\": 64 + \\"id\\": 79 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 66 + \\"id\\": 81 } ], \\"rootId\\": 11, - \\"id\\": 59 + \\"id\\": 74 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 67 + \\"id\\": 82 } ], \\"rootId\\": 11, - \\"id\\": 57 + \\"id\\": 72 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 68 + \\"id\\": 83 }, { \\"type\\": 2, @@ -2559,7 +2894,7 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] = \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 70 + \\"id\\": 85 }, { \\"type\\": 2, @@ -2569,23 +2904,119 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] = }, \\"childNodes\\": [], \\"rootId\\": 11, - \\"id\\": 71 + \\"id\\": 86 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 87 + } + ], + \\"rootId\\": 11, + \\"id\\": 84 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 88 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": { + \\"for\\": \\"empty\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 90 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"id\\": \\"empty\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 11, + \\"id\\": 91 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 92 + } + ], + \\"rootId\\": 11, + \\"id\\": 89 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 93 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": { + \\"for\\": \\"unmask\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 95 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\", + \\"class\\": \\"rr-unmask\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 11, + \\"id\\": 96 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 72 + \\"id\\": 97 } ], \\"rootId\\": 11, - \\"id\\": 69 + \\"id\\": 94 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 11, + \\"id\\": 98 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"submit\\", + \\"value\\": \\"Submit form\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 11, + \\"id\\": 99 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", \\"rootId\\": 11, - \\"id\\": 73 + \\"id\\": 100 } ], \\"rootId\\": 11, @@ -2595,7 +3026,7 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] = \\"type\\": 3, \\"textContent\\": \\"\\\\n \\\\n\\\\n\\", \\"rootId\\": 11, - \\"id\\": 74 + \\"id\\": 101 } ], \\"rootId\\": 11, diff --git a/packages/rrweb/test/replayer.test.ts b/packages/rrweb/test/replayer.test.ts index 7756710410..183d2417fb 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -22,6 +22,7 @@ import adoptedStyleSheet from './events/adopted-style-sheet'; import adoptedStyleSheetModification from './events/adopted-style-sheet-modification'; import documentReplacementEvents from './events/document-replacement'; import hoverInIframeShadowDom from './events/iframe-shadowdom-hover'; +import customElementDefineClass from './events/custom-element-define-class'; import { ReplayerEvents } from '@rrweb/types'; interface ISuite { @@ -1076,4 +1077,19 @@ describe('replayer', function () { ), ).toBe(':hover'); }); + + it('should replay styles with :define pseudo-class', async () => { + await page.evaluate(`events = ${JSON.stringify(customElementDefineClass)}`); + + const displayValue = await page.evaluate(` + const { Replayer } = rrweb; + const replayer = new Replayer(events); + replayer.pause(200); + const customElement = replayer.iframe.contentDocument.querySelector('custom-element'); + window.getComputedStyle(customElement).display; + `); + // If the custom element is not defined, the display value will be 'none'. + // If the custom element is defined, the display value will be 'block'. + expect(displayValue).toEqual('block'); + }); }); diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index dd5a8cf7cc..01c78ffe2a 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -588,6 +588,13 @@ export async function waitForRAF( } export function generateRecordSnippet(options: recordOptions) { + // XXX(sentry) + // maskInputSelector: ${JSON.stringify(options.maskInputSelector)}, + // onMutation: ${options.onMutation || undefined}, + // maskAllText: ${options.maskAllText}, + // unmaskTextSelector: ${JSON.stringify(options.unmaskTextSelector)}, + // unmaskInputSelector: ${JSON.stringify(options.unmaskInputSelector)}, + // unblockSelector: ${JSON.stringify(options.unblockSelector)}, return ` window.snapshots = []; rrweb.record({ @@ -595,11 +602,14 @@ export function generateRecordSnippet(options: recordOptions) { window.snapshots.push(event); }, maskTextSelector: ${JSON.stringify(options.maskTextSelector)}, + blockSelector: ${JSON.stringify(options.blockSelector)}, maskAllInputs: ${options.maskAllInputs}, maskInputOptions: ${JSON.stringify(options.maskAllInputs)}, + maskInputFn: ${options.maskInputFn}, userTriggeredOnInput: ${options.userTriggeredOnInput}, maskTextFn: ${options.maskTextFn}, maskInputFn: ${options.maskInputFn}, + blockSelector: ${JSON.stringify(options.blockSelector)}, recordCanvas: ${options.recordCanvas}, recordAfter: '${options.recordAfter || 'load'}', inlineImages: ${options.inlineImages}, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6601457291..fe4e770ddd 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -83,6 +83,7 @@ export enum IncrementalSource { StyleDeclaration, Selection, AdoptedStyleSheet, + CustomElement, } export type mutationData = { @@ -142,6 +143,10 @@ export type adoptedStyleSheetData = { source: IncrementalSource.AdoptedStyleSheet; } & adoptedStyleSheetParam; +export type customElementData = { + source: IncrementalSource.CustomElement; +} & customElementParam; + export type incrementalData = | mutationData | mousemoveData @@ -155,7 +160,8 @@ export type incrementalData = | fontData | selectionData | styleDeclarationData - | adoptedStyleSheetData; + | adoptedStyleSheetData + | customElementData; export type event = | domContentLoadedEvent @@ -179,6 +185,7 @@ export type canvasEventWithTime = eventWithTime & { export type blockClass = string | RegExp; export type maskTextClass = string | RegExp; +export type unmaskTextClass = string | RegExp | null; export type SamplingStrategy = Partial<{ /** @@ -262,6 +269,7 @@ export type hooksParam = { canvasMutation?: canvasMutationCallback; font?: fontCallback; selection?: selectionCallback; + customElement?: customElementCallback; }; // https://dom.spec.whatwg.org/#interface-mutationrecord @@ -591,6 +599,14 @@ export type selectionParam = { export type selectionCallback = (p: selectionParam) => void; +export type customElementParam = { + define?: { + name: string; + }; +}; + +export type customElementCallback = (c: customElementParam) => void; + export type DeprecatedMirror = { map: { [key: number]: INode;