diff --git a/guide.md b/guide.md index 76f781d096..c2de0111f6 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 | 'rr-unmask' | 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 | @@ -170,6 +173,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 class name `.rr-unmask` 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/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 99a23ff7be..fba069dcf4 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -280,26 +280,89 @@ export function classMatchesRegex( regex: RegExp, checkAncestors: boolean, ): boolean { - if (!node) return false; + return distanceToClassRegexMatch(node, regex, checkAncestors) >= 0; +} + +function distanceToClassRegexMatch( + node: Node | null, + regex: RegExp, + checkAncestors: boolean, + distance = 0, +): number { + if (!node) return -1; if (node.nodeType !== node.ELEMENT_NODE) { - if (!checkAncestors) return false; - return classMatchesRegex(node.parentNode, regex, checkAncestors); + if (!checkAncestors) return -1; + return distanceToClassRegexMatch(node.parentNode, regex, checkAncestors); } for (let eIndex = (node as HTMLElement).classList.length; eIndex--; ) { const className = (node as HTMLElement).classList[eIndex]; if (regex.test(className)) { - return true; + return distance; } } - if (!checkAncestors) return false; - return classMatchesRegex(node.parentNode, regex, checkAncestors); + if (!checkAncestors) return -1; + return distanceToClassRegexMatch( + node.parentNode, + regex, + checkAncestors, + distance + 1, + ); +} + +function distanceToSelectorMatch(el: HTMLElement, selector: string): number { + if (!el) return -1; + if (el.matches(selector)) return 0; + const closestParent = el.closest(selector); + if (closestParent) { + let current = el; + let distance = 0; + while (current && current !== closestParent) { + current = current.parentNode as HTMLElement; + if (!current) { + return -1; + } + distance++; + } + return distance; + } + return -1; +} + +function distanceToMatch( + el: HTMLElement, + className: string | RegExp, + selector: string | null, +): number { + let classDistance = -1; + let selectorDistance = -1; + + if (typeof className === 'string') { + classDistance = distanceToSelectorMatch(el, `.${className}`); + } else { + classDistance = distanceToClassRegexMatch(el, className, true); + } + + if (selector) { + selectorDistance = distanceToSelectorMatch(el, selector); + } + + return selectorDistance >= 0 + ? classDistance >= 0 + ? Math.min(classDistance, selectorDistance) + : selectorDistance + : classDistance >= 0 + ? classDistance + : -1; } export function needMaskingText( node: Node, maskTextClass: string | RegExp, maskTextSelector: string | null, + unmaskTextClass: string | RegExp, + unmaskTextSelector: string | null, + maskAllText: boolean, ): boolean { const el: HTMLElement | null = node.nodeType === node.ELEMENT_NODE @@ -307,18 +370,20 @@ 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; - } else { - if (classMatchesRegex(el, maskTextClass, true)) return true; - } + const maskDistance = distanceToMatch(el, maskTextClass, maskTextSelector); + const unmaskDistance = distanceToMatch( + el, + unmaskTextClass, + unmaskTextSelector, + ); - if (maskTextSelector) { - if (el.matches(maskTextSelector)) return true; - if (el.closest(maskTextSelector)) return true; - } - return false; + return maskDistance >= 0 + ? unmaskDistance >= 0 + ? maskDistance <= unmaskDistance + : true + : unmaskDistance >= 0 + ? false + : !!maskAllText; } // https://stackoverflow.com/a/36155560 @@ -412,8 +477,11 @@ function serializeNode( mirror: Mirror; blockClass: string | RegExp; blockSelector: string | null; + maskAllText: boolean; maskTextClass: string | RegExp; + unmaskTextClass: string | RegExp; maskTextSelector: string | null; + unmaskTextSelector: string | null; inlineStylesheet: boolean; maskInputOptions: MaskInputOptions; maskTextFn: MaskTextFn | undefined; @@ -433,8 +501,11 @@ function serializeNode( mirror, blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, inlineStylesheet, maskInputOptions = {}, maskTextFn, @@ -486,8 +557,11 @@ function serializeNode( }); case n.TEXT_NODE: return serializeTextNode(n as Text, { + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, maskTextFn, rootId, }); @@ -517,13 +591,24 @@ function getRootId(doc: Document, mirror: Mirror): number | undefined { function serializeTextNode( n: Text, options: { + maskAllText: boolean; maskTextClass: string | RegExp; + unmaskTextClass: string | RegExp; 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; @@ -558,7 +643,14 @@ function serializeTextNode( !isStyle && !isScript && textContent && - needMaskingText(n, maskTextClass, maskTextSelector) + needMaskingText( + n, + maskTextClass, + maskTextSelector, + unmaskTextClass, + unmaskTextSelector, + maskAllText, + ) ) { textContent = maskTextFn ? maskTextFn(textContent) @@ -900,11 +992,14 @@ export function serializeNodeWithId( blockClass: string | RegExp; blockSelector: string | null; maskTextClass: string | RegExp; + unmaskTextClass: string | RegExp; maskTextSelector: string | null; + unmaskTextSelector: string | null; skipChild: boolean; inlineStylesheet: boolean; newlyAddedElement?: boolean; maskInputOptions?: MaskInputOptions; + maskAllText: boolean; maskTextFn: MaskTextFn | undefined; maskInputFn: MaskInputFn | undefined; slimDOMOptions: SlimDOMOptions; @@ -931,8 +1026,11 @@ export function serializeNodeWithId( mirror, blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, skipChild = false, inlineStylesheet = true, maskInputOptions = {}, @@ -956,8 +1054,11 @@ export function serializeNodeWithId( mirror, blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, inlineStylesheet, maskInputOptions, maskTextFn, @@ -1028,8 +1129,11 @@ export function serializeNodeWithId( mirror, blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, skipChild, inlineStylesheet, maskInputOptions, @@ -1088,8 +1192,11 @@ export function serializeNodeWithId( mirror, blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, skipChild: false, inlineStylesheet, maskInputOptions, @@ -1135,8 +1242,11 @@ export function serializeNodeWithId( mirror, blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, skipChild: false, inlineStylesheet, maskInputOptions, @@ -1176,8 +1286,11 @@ function snapshot( mirror?: Mirror; blockClass?: string | RegExp; blockSelector?: string | null; + maskAllText?: boolean; maskTextClass?: string | RegExp; + unmaskTextClass?: string | RegExp; maskTextSelector?: string | null; + unmaskTextSelector?: string | null; inlineStylesheet?: boolean; maskAllInputs?: boolean | MaskInputOptions; maskTextFn?: MaskTextFn; @@ -1205,8 +1318,11 @@ function snapshot( mirror = new Mirror(), blockClass = 'rr-block', blockSelector = null, + maskAllText = false, maskTextClass = 'rr-mask', + unmaskTextClass = 'rr-unmask', maskTextSelector = null, + unmaskTextSelector = null, inlineStylesheet = true, inlineImages = false, recordCanvas = false, @@ -1271,8 +1387,11 @@ function snapshot( mirror, blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, skipChild: false, inlineStylesheet, maskInputOptions, diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts index 75d635e0c0..9926d1c8bb 100644 --- a/packages/rrweb-snapshot/test/snapshot.test.ts +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -6,6 +6,7 @@ import { absoluteToStylesheet, serializeNodeWithId, _isBlockedElement, + needMaskingText, } from '../src/snapshot'; import { serializedNodeWithId } from '../src/types'; import { Mirror } from '../src/utils'; @@ -143,8 +144,11 @@ describe('style elements', () => { mirror: new Mirror(), blockClass: 'blockblock', blockSelector: null, + maskAllText: false, maskTextClass: 'maskmask', + unmaskTextClass: 'unmaskmask', maskTextSelector: null, + unmaskTextSelector: null, skipChild: false, inlineStylesheet: true, maskTextFn: undefined, @@ -188,8 +192,11 @@ describe('scrollTop/scrollLeft', () => { mirror: new Mirror(), blockClass: 'blockblock', blockSelector: null, + maskAllText: false, maskTextClass: 'maskmask', + unmaskTextClass: 'unmaskmask', maskTextSelector: null, + unmaskTextSelector: null, skipChild: false, inlineStylesheet: true, maskTextFn: undefined, @@ -218,3 +225,222 @@ describe('scrollTop/scrollLeft', () => { }); }); }); + +describe('needMaskingText', () => { + const render = (html: string): HTMLDivElement => { + document.write(html); + return document.querySelector('div')!; + }; + + it('should not mask by default', () => { + const el = render(`
Lorem ipsum
`); + expect( + needMaskingText(el, 'maskmask', null, 'unmaskmask', null, false), + ).toEqual(false); + }); + + it('should mask if the masking class is matched', () => { + const el = render(`
Lorem ipsum
`); + expect( + needMaskingText(el, 'maskmask', null, 'unmaskmask', null, false), + ).toEqual(true); + expect( + needMaskingText(el, /^maskmask$/, null, /^unmaskmask$/, null, false), + ).toEqual(true); + }); + + it('should mask if the masking class is matched on an ancestor', () => { + const el = render( + `
Lorem ipsum
`, + ); + expect( + needMaskingText( + el.children[0], + 'maskmask', + null, + 'unmaskmask', + null, + false, + ), + ).toEqual(true); + expect( + needMaskingText( + el.children[0], + /^maskmask$/, + null, + /^unmaskmask$/, + null, + false, + ), + ).toEqual(true); + }); + + it('should mask if the masking selector is matched', () => { + const el = render(`
Lorem ipsum
`); + expect( + needMaskingText(el, 'maskmask', '.foo', 'unmaskmask', null, false), + ).toEqual(true); + }); + + it('should mask if the masking selector is matched on an ancestor', () => { + const el = render(`
Lorem ipsum
`); + expect( + needMaskingText( + el.children[0], + 'maskmask', + '.foo', + 'unmaskmask', + null, + false, + ), + ).toEqual(true); + }); + + it('should mask by default', () => { + const el = render(`
Lorem ipsum
`); + expect( + needMaskingText(el, 'maskmask', null, 'unmaskmask', null, true), + ).toEqual(true); + }); + + it('should not mask if the un-masking class is matched', () => { + const el = render(`
Lorem ipsum
`); + expect( + needMaskingText(el, 'maskmask', null, 'unmaskmask', null, true), + ).toEqual(false); + expect( + needMaskingText(el, /^maskmask$/, null, /^unmaskmask$/, null, true), + ).toEqual(false); + }); + + it('should not mask if the un-masking class is matched on an ancestor', () => { + const el = render( + `
Lorem ipsum
`, + ); + expect( + needMaskingText( + el.children[0], + 'maskmask', + null, + 'unmaskmask', + null, + true, + ), + ).toEqual(false); + expect( + needMaskingText( + el.children[0], + /^maskmask$/, + null, + /^unmaskmask$/, + null, + true, + ), + ).toEqual(false); + }); + + it('should mask if the masking class is more specific than the unmasking class', () => { + const el = render( + `
Lorem ipsum
`, + ); + expect( + needMaskingText( + el.children[0].children[0], + 'maskmask', + null, + 'unmaskmask', + null, + true, + ), + ).toEqual(true); + expect( + needMaskingText( + el.children[0].children[0], + /^maskmask$/, + null, + /^unmaskmask$/, + null, + true, + ), + ).toEqual(true); + }); + + it('should not mask if the unmasking class is more specific than the masking class', () => { + const el = render( + `
Lorem ipsum
`, + ); + expect( + needMaskingText( + el.children[0].children[0], + 'maskmask', + null, + 'unmaskmask', + null, + true, + ), + ).toEqual(false); + expect( + needMaskingText( + el.children[0].children[0], + /^maskmask$/, + null, + /^unmaskmask$/, + null, + true, + ), + ).toEqual(false); + }); + + it('should not mask if the unmasking selector is matched', () => { + const el = render(`
Lorem ipsum
`); + expect( + needMaskingText(el, 'maskmask', null, 'unmaskmask', '.foo', true), + ).toEqual(false); + }); + + it('should not mask if the unmasking selector is matched on an ancestor', () => { + const el = render(`
Lorem ipsum
`); + expect( + needMaskingText( + el.children[0], + 'maskmask', + null, + 'unmaskmask', + '.foo', + true, + ), + ).toEqual(false); + }); + + it('should mask if the masking selector is more specific than the unmasking selector', () => { + const el = render( + `
Lorem ipsum
`, + ); + expect( + needMaskingText( + el.children[0].children[0], + 'maskmask', + '.bar', + 'unmaskmask', + '.foo', + true, + ), + ).toEqual(true); + }); + + it('should not mask if the unmasking selector is more specific than the masking selector', () => { + const el = render( + `
Lorem ipsum
`, + ); + expect( + needMaskingText( + el.children[0].children[0], + 'maskmask', + '.bar', + 'unmaskmask', + '.foo', + true, + ), + ).toEqual(false); + }); +}); diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index e40d971613..2f4b9504d7 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -56,8 +56,11 @@ function record( blockClass = 'rr-block', blockSelector = null, ignoreClass = 'rr-ignore', + maskAllText = false, maskTextClass = 'rr-mask', + unmaskTextClass = 'rr-unmask', maskTextSelector = null, + unmaskTextSelector = null, inlineStylesheet = true, maskAllInputs, maskInputOptions: _maskInputOptions, @@ -300,8 +303,11 @@ function record( bypassOptions: { blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, inlineStylesheet, maskInputOptions, dataURLOptions, @@ -340,8 +346,11 @@ function record( mirror, blockClass, blockSelector, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, inlineStylesheet, maskAllInputs: maskInputOptions, maskTextFn, @@ -520,8 +529,11 @@ function record( }, blockClass, ignoreClass, + maskAllText, maskTextClass, + unmaskTextClass, maskTextSelector, + unmaskTextSelector, maskInputOptions, inlineStylesheet, sampling, diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 179272c098..3a7b05615d 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -158,8 +158,11 @@ export default class MutationBuffer { private mutationCb: observerParam['mutationCb']; private blockClass: observerParam['blockClass']; private blockSelector: observerParam['blockSelector']; + private maskAllText: observerParam['maskAllText']; private maskTextClass: observerParam['maskTextClass']; + private unmaskTextClass: observerParam['unmaskTextClass']; private maskTextSelector: observerParam['maskTextSelector']; + private unmaskTextSelector: observerParam['unmaskTextSelector']; private inlineStylesheet: observerParam['inlineStylesheet']; private maskInputOptions: observerParam['maskInputOptions']; private maskTextFn: observerParam['maskTextFn']; @@ -181,8 +184,11 @@ export default class MutationBuffer { 'mutationCb', 'blockClass', 'blockSelector', + 'maskAllText', 'maskTextClass', + 'unmaskTextClass', 'maskTextSelector', + 'unmaskTextSelector', 'inlineStylesheet', 'maskInputOptions', 'maskTextFn', @@ -298,8 +304,11 @@ export default class MutationBuffer { mirror: this.mirror, blockClass: this.blockClass, blockSelector: this.blockSelector, + maskAllText: this.maskAllText, maskTextClass: this.maskTextClass, + unmaskTextClass: this.unmaskTextClass, maskTextSelector: this.maskTextSelector, + unmaskTextSelector: this.unmaskTextSelector, skipChild: true, newlyAddedElement: true, inlineStylesheet: this.inlineStylesheet, @@ -485,6 +494,9 @@ export default class MutationBuffer { m.target, this.maskTextClass, this.maskTextSelector, + this.unmaskTextClass, + this.unmaskTextSelector, + this.maskAllText, ) && value ? this.maskTextFn ? this.maskTextFn(value) diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 4548717cd6..9a85427f89 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -25,6 +25,7 @@ import type { KeepIframeSrcFn, listenerHandler, maskTextClass, + unmaskTextClass, mediaInteractionCallback, mouseInteractionCallBack, mousemoveCallBack, @@ -45,8 +46,11 @@ export type recordOptions = { blockClass?: blockClass; blockSelector?: string; ignoreClass?: string; + maskAllText?: boolean; maskTextClass?: maskTextClass; + unmaskTextClass?: unmaskTextClass; maskTextSelector?: string; + unmaskTextSelector?: string; maskAllInputs?: boolean; maskInputOptions?: MaskInputOptions; maskInputFn?: MaskInputFn; @@ -81,8 +85,11 @@ export type observerParam = { blockClass: blockClass; blockSelector: string | null; ignoreClass: string; + maskAllText: boolean; maskTextClass: maskTextClass; + unmaskTextClass: unmaskTextClass; maskTextSelector: string | null; + unmaskTextSelector: string | null; maskInputOptions: MaskInputOptions; maskInputFn?: MaskInputFn; maskTextFn?: MaskTextFn; @@ -122,8 +129,11 @@ export type MutationBufferParam = Pick< | 'mutationCb' | 'blockClass' | 'blockSelector' + | 'maskAllText' | 'maskTextClass' + | 'unmaskTextClass' | 'maskTextSelector' + | 'unmaskTextSelector' | 'inlineStylesheet' | 'maskInputOptions' | 'maskTextFn' diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index f2d86c86bb..feb4f59aaa 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -3106,6 +3106,349 @@ exports[`record integration tests can record node mutations 1`] = ` ]" `; +exports[`record integration tests can selectively unmask parts of the page 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"http-equiv\\": \\"X-UA-Compatible\\", + \\"content\\": \\"ie=edge\\" + }, + \\"childNodes\\": [], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 11 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Unmask text\\", + \\"id\\": 13 + } + ], + \\"id\\": 12 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 14 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 17 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"class\\": \\"rr-unmask\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"unmask1\\\\n \\", + \\"id\\": 19 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"class\\": \\"rr-mask\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 21 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"span\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"*****\\", + \\"id\\": 23 + } + ], + \\"id\\": 22 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 24 + } + ], + \\"id\\": 20 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 25 + } + ], + \\"id\\": 18 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 26 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"data-masking\\": \\"false\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 28 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 30 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"data-masking\\": \\"true\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"*****\\", + \\"id\\": 32 + } + ], + \\"id\\": 31 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 33 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"unmask2\\", + \\"id\\": 35 + } + ], + \\"id\\": 34 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 36 + } + ], + \\"id\\": 29 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 37 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"ul\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 38 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 39 + } + ], + \\"id\\": 27 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\", + \\"id\\": 40 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 42 + } + ], + \\"id\\": 41 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 43 + } + ], + \\"id\\": 16 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 38, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"li\\", + \\"attributes\\": { + \\"class\\": \\"rr-mask\\" + }, + \\"childNodes\\": [], + \\"id\\": 44 + } + }, + { + \\"parentId\\": 44, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"*** **** ****\\", + \\"id\\": 45 + } + } + ] + } + } +]" +`; + exports[`record integration tests can use maskInputOptions to configure which type of inputs should be masked 1`] = ` "[ { 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.test.ts b/packages/rrweb/test/integration.test.ts index 82ce7d1933..a5e3ab29a7 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -871,4 +871,29 @@ describe('record integration tests', function (this: ISuite) { )) as eventWithTime[]; 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); + }); }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d2ed8ded5b..d2cf125b87 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -179,6 +179,7 @@ export type canvasEventWithTime = eventWithTime & { export type blockClass = string | RegExp; export type maskTextClass = string | RegExp; +export type unmaskTextClass = string | RegExp; export type SamplingStrategy = Partial<{ /**