From d3d330ac2ef47c2cd8f998070dc690b4d048efda Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 27 Feb 2023 09:36:38 +0100 Subject: [PATCH 1/4] WIP --- packages/rrweb-snapshot/src/snapshot.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 7955edff83..62991cbbe7 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -242,32 +242,36 @@ export function transformAttribute( maskAllText: boolean, maskTextFn: MaskTextFn | undefined, ): string { + if(!value) { + return value; + } + // relative path in attribute - if (name === 'src' || (name === 'href' && value)) { + if (name === 'src' || name === 'href') { return absoluteToDoc(doc, value); - } else if (name === 'xlink:href' && value && value[0] !== '#') { + } else if (name === 'xlink:href' && value[0] !== '#') { // xlink:href starts with # is an id pointer return absoluteToDoc(doc, value); } else if ( name === 'background' && - value && (tagName === 'table' || tagName === 'td' || tagName === 'th') ) { return absoluteToDoc(doc, value); - } else if (name === 'srcset' && value) { + } else if (name === 'srcset') { return getAbsoluteSrcsetString(doc, value); - } else if (name === 'style' && value) { + } else if (name === 'style') { return absoluteToStylesheet(value, getHref()); - } else if (tagName === 'object' && name === 'data' && value) { + } else if (tagName === 'object' && name === 'data') { return absoluteToDoc(doc, value); } else if ( maskAllText && ['placeholder', 'title', 'aria-label'].indexOf(name) > -1 ) { return maskTextFn ? maskTextFn(value) : defaultMaskFn(value); - } else { + } + return value; - } + } export function _isBlockedElement( From 60df6623b051252e1a0540580164fc391d672d8d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 27 Feb 2023 09:37:12 +0100 Subject: [PATCH 2/4] WIP add test --- .../__snapshots__/integration.test.ts.snap | 199 ++++++++++++++++++ packages/rrweb/test/integration.test.ts | 31 +++ 2 files changed, 230 insertions(+) diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index 86f4466ac9..782918e17f 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -3684,6 +3684,205 @@ exports[`record integration tests can use maskInputOptions to configure which ty ]" `; +exports[`record integration tests handles null attribute values 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": {}, + "childNodes": [ + { + "type": 2, + "tagName": "head", + "attributes": {}, + "childNodes": [], + "id": 4 + }, + { + "type": 2, + "tagName": "body", + "attributes": {}, + "childNodes": [ + { + "type": 3, + "textContent": "\\n ", + "id": 6 + }, + { + "type": 2, + "tagName": "p", + "attributes": {}, + "childNodes": [ + { + "type": 3, + "textContent": "******** ********", + "id": 8 + } + ], + "id": 7 + }, + { + "type": 3, + "textContent": "\\n ", + "id": 9 + }, + { + "type": 2, + "tagName": "ul", + "attributes": {}, + "childNodes": [ + { + "type": 3, + "textContent": "\\n ", + "id": 11 + }, + { + "type": 2, + "tagName": "li", + "attributes": {}, + "childNodes": [], + "id": 12 + }, + { + "type": 3, + "textContent": "\\n ", + "id": 13 + } + ], + "id": 10 + }, + { + "type": 3, + "textContent": "\\n ", + "id": 14 + }, + { + "type": 2, + "tagName": "canvas", + "attributes": {}, + "childNodes": [], + "id": 15 + }, + { + "type": 3, + "textContent": "\\n\\n ", + "id": 16 + }, + { + "type": 2, + "tagName": "script", + "attributes": {}, + "childNodes": [ + { + "type": 3, + "textContent": "SCRIPT_PLACEHOLDER", + "id": 18 + } + ], + "id": 17 + }, + { + "type": 3, + "textContent": "\\n \\n \\n", + "id": 19 + } + ], + "id": 5 + } + ], + "id": 3 + } + ], + "id": 1 + }, + "initialOffset": { + "left": 0, + "top": 0 + } + } + }, + { + "type": 3, + "data": { + "source": 0, + "texts": [], + "attributes": [ + { + "id": 20, + "attributes": { + "aria-label": "*****", + "id": "test-li" + } + } + ], + "removes": [], + "adds": [ + { + "parentId": 10, + "nextId": null, + "node": { + "type": 2, + "tagName": "li", + "attributes": { + "aria-label": "*****", + "id": "test-li" + }, + "childNodes": [], + "id": 20 + } + } + ] + } + }, + { + "type": 3, + "data": { + "source": 0, + "texts": [], + "attributes": [ + { + "id": 20, + "attributes": { + "aria-label": null + } + } + ], + "removes": [], + "adds": [] + } + } +]" +`; + exports[`record integration tests should mask all text (except unmaskTextSelector), using maskAllText 1`] = ` "[ { diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 8283fa482f..9c09082a5a 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -130,6 +130,37 @@ describe('record integration tests', function (this: ISuite) { assertSnapshot(snapshots); }); + 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', { + maskAllInputs: true, + maskAllText: true, + }), + ); + + await page.evaluate(() => { + const li = document.createElement('li'); + const ul = document.querySelector('ul') as HTMLUListElement; + ul.appendChild(li); + + li.setAttribute('aria-label', 'label'); + li.setAttribute('id', 'test-li'); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + await page.evaluate(() => { + const li = document.querySelector('#test-li') as HTMLLIElement; + // This triggers the mutation observer with a `null` attribute value + li.removeAttribute('aria-label'); + }); + + const snapshots = await page.evaluate('window.snapshots'); + assertSnapshot(snapshots); + }); + it('can record character data muatations', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); From e84233be46a34b3c13a17e120494894442d00917 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 27 Feb 2023 09:43:01 +0100 Subject: [PATCH 3/4] fix formatting --- packages/rrweb-snapshot/src/snapshot.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 62991cbbe7..022fca2bda 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -242,7 +242,7 @@ export function transformAttribute( maskAllText: boolean, maskTextFn: MaskTextFn | undefined, ): string { - if(!value) { + if (!value) { return value; } @@ -268,10 +268,9 @@ export function transformAttribute( ['placeholder', 'title', 'aria-label'].indexOf(name) > -1 ) { return maskTextFn ? maskTextFn(value) : defaultMaskFn(value); - } + } - return value; - + return value; } export function _isBlockedElement( From 8a2a2be2d5a0a9e9c98f826a7da08f78042dd990 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 27 Feb 2023 10:13:04 +0100 Subject: [PATCH 4/4] fix types that value can be `null` --- packages/rrweb-snapshot/src/rebuild.ts | 2 +- packages/rrweb-snapshot/src/snapshot.ts | 8 ++++---- packages/rrweb-snapshot/src/types.ts | 2 +- packages/rrweb-snapshot/typings/snapshot.d.ts | 2 +- packages/rrweb-snapshot/typings/types.d.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 263ad99181..34c8766ffd 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -154,7 +154,7 @@ function buildNode( continue; } value = - typeof value === 'boolean' || typeof value === 'number' ? '' : value; + typeof value === 'boolean' || typeof value === 'number' || value === null ? '' : value; // attribute names start with rr_ are internal attributes added by rrweb if (!name.startsWith('rr_')) { const isTextarea = tagName === 'textarea' && name === 'value'; diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 022fca2bda..f1092f2c25 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -238,10 +238,10 @@ export function transformAttribute( doc: Document, tagName: string, name: string, - value: string, + value: string | null, maskAllText: boolean, maskTextFn: MaskTextFn | undefined, -): string { +): string | null { if (!value) { return value; } @@ -773,8 +773,8 @@ function serializeNode( } } -function lowerIfExists(maybeAttr: string | number | boolean): string { - if (maybeAttr === undefined) { +function lowerIfExists(maybeAttr: string | number | boolean | null | undefined): string { + if (maybeAttr === undefined || maybeAttr === null) { return ''; } else { return (maybeAttr as string).toLowerCase(); diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index eedd49252f..834d4d2e40 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -21,7 +21,7 @@ export type documentTypeNode = { }; export type attributes = { - [key: string]: string | number | boolean; + [key: string]: string | number | boolean | null; }; export type elementNode = { type: NodeType.Element; diff --git a/packages/rrweb-snapshot/typings/snapshot.d.ts b/packages/rrweb-snapshot/typings/snapshot.d.ts index 65cad17f4f..0f4ec0b1b1 100644 --- a/packages/rrweb-snapshot/typings/snapshot.d.ts +++ b/packages/rrweb-snapshot/typings/snapshot.d.ts @@ -2,7 +2,7 @@ import { serializedNodeWithId, INode, idNodeMap, MaskInputOptions, SlimDOMOption export declare const IGNORED_NODE = -2; export declare function absoluteToStylesheet(cssText: string | null, href: string): string; export declare function absoluteToDoc(doc: Document, attributeValue: string): string; -export declare function transformAttribute(doc: Document, tagName: string, name: string, value: string, maskAllText: boolean, maskTextFn: MaskTextFn | undefined): string; +export declare function transformAttribute(doc: Document, tagName: string, name: string, value: string | null, maskAllText: boolean, maskTextFn: MaskTextFn | undefined): string | null; export declare function _isBlockedElement(element: HTMLElement, blockClass: string | RegExp, blockSelector: string | null, unblockSelector: string | null): boolean; export declare function needMaskingText(node: Node | null, maskTextClass: string | RegExp, maskTextSelector: string | null, unmaskTextSelector: string | null, maskAllText: boolean): boolean; export declare function serializeNodeWithId(n: Node | INode, options: { diff --git a/packages/rrweb-snapshot/typings/types.d.ts b/packages/rrweb-snapshot/typings/types.d.ts index 4d1eb144e8..6d0fc98602 100644 --- a/packages/rrweb-snapshot/typings/types.d.ts +++ b/packages/rrweb-snapshot/typings/types.d.ts @@ -18,7 +18,7 @@ export type documentTypeNode = { systemId: string; }; export type attributes = { - [key: string]: string | number | boolean; + [key: string]: string | number | boolean | null; }; export type elementNode = { type: NodeType.Element;