Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[REPLAY] Add support for adoptedStyleSheets #1916

Merged
merged 14 commits into from
Jan 12, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/core/test/specHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,3 +498,7 @@ export function interceptRequests() {
},
}
}

export function isAdoptedStyleSheetsSupported() {
return Boolean((document as any).adoptedStyleSheets)
}
2 changes: 2 additions & 0 deletions packages/rum-core/test/createIsolatedDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export function createIsolatedDom() {
element(s: TemplateStringsArray) {
return append(s[0])
},
document: doc,
window: iframe.contentWindow! as Window & { CSSStyleSheet: typeof CSSStyleSheet },
append,
clear() {
iframe.parentNode!.removeChild(iframe)
Expand Down
4 changes: 4 additions & 0 deletions packages/rum/src/domain/record/browser.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// TODO: remove this once typescript has been update to 4.8+
export type WithAdoptedStyleSheets = {
adoptedStyleSheets: CSSStyleSheet[] | undefined
} & (Document | ShadowRoot)
26 changes: 26 additions & 0 deletions packages/rum/src/domain/record/serializationUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { isIE } from '@datadog/browser-core'
import { isAdoptedStyleSheetsSupported } from '../../../../core/test/specHelper'
import { NodePrivacyLevel } from '../../constants'
import {
getSerializedNodeId,
hasSerializedNode,
setSerializedNodeId,
getElementInputValue,
switchToAbsoluteUrl,
serializeStyleSheets,
} from './serializationUtils'

describe('serialized Node storage in DOM Nodes', () => {
Expand Down Expand Up @@ -183,3 +185,27 @@ describe('switchToAbsoluteUrl', () => {
})
})
})

describe('getStyleSheets', () => {
ThibautGeriz marked this conversation as resolved.
Show resolved Hide resolved
beforeEach(() => {
if (!isAdoptedStyleSheetsSupported()) {
pending('no adoptedStyleSheets support')
}
})
it('should return undefined if no stylesheets', () => {
expect(serializeStyleSheets(undefined)).toBe(undefined)
expect(serializeStyleSheets([])).toBe(undefined)
})

it('should return serialized stylesheet', () => {
const disabledStylesheet = new CSSStyleSheet({ disabled: true })
disabledStylesheet.insertRule('div { width: 100%; }')
const printStylesheet = new CSSStyleSheet({ disabled: false, media: 'print' })
printStylesheet.insertRule('a { color: red; }')

expect(serializeStyleSheets([disabledStylesheet, printStylesheet])).toEqual([
{ cssRules: ['div { width: 100%; }'], disabled: true, media: undefined },
{ cssRules: ['a { color: red; }'], disabled: undefined, media: ['print'] },
])
})
})
18 changes: 18 additions & 0 deletions packages/rum/src/domain/record/serializationUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { buildUrl } from '@datadog/browser-core'
import { getParentNode, isNodeShadowRoot } from '@datadog/browser-rum-core'
import type { NodePrivacyLevel } from '../../constants'
import { CENSORED_STRING_MARK } from '../../constants'
import type { StyleSheet } from '../../types'
import { shouldMaskNode } from './privacy'

export type NodeWithSerializedNode = Node & { s: 'Node with serialized node' }
Expand Down Expand Up @@ -106,3 +107,20 @@ export function makeUrlAbsolute(url: string, baseUrl: string): string {
return url
}
}

export function serializeStyleSheets(cssStyleSheets: CSSStyleSheet[] | undefined): StyleSheet[] | undefined {
if (cssStyleSheets === undefined || cssStyleSheets.length === 0) {
return undefined
}
return cssStyleSheets.map((cssStyleSheet) => {
const rules = cssStyleSheet.cssRules || cssStyleSheet.rules
const cssRules = Array.from(rules, (cssRule) => cssRule.cssText)

const styleSheet: StyleSheet = {
cssRules,
disabled: cssStyleSheet.disabled || undefined,
media: cssStyleSheet.media.length > 0 ? Array.from(cssStyleSheet.media) : undefined,
}
return styleSheet
})
}
42 changes: 42 additions & 0 deletions packages/rum/src/domain/record/serialize.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isIE, noop, resetExperimentalFeatures, updateExperimentalFeatures } from '@datadog/browser-core'

import type { RumConfiguration } from '@datadog/browser-rum-core'
import { STABLE_ATTRIBUTES, DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE } from '@datadog/browser-rum-core'
import {
Expand All @@ -9,6 +10,7 @@ import {
PRIVACY_ATTR_VALUE_MASK,
PRIVACY_ATTR_VALUE_MASK_USER_INPUT,
} from '../../constants'
import { isAdoptedStyleSheetsSupported } from '../../../../core/test/specHelper'
import {
HTML,
AST_ALLOW,
Expand All @@ -19,6 +21,8 @@ import {
} from '../../../test/htmlAst'
import type { ElementNode, SerializedNodeWithId, TextNode } from '../../types'
import { NodeType } from '../../types'
import type { IsolatedDom } from '../../../../rum-core/test/createIsolatedDom'
import { createIsolatedDom } from '../../../../rum-core/test/createIsolatedDom'
import { hasSerializedNode } from './serializationUtils'
import type { SerializationContext, SerializeOptions } from './serialize'
import {
Expand All @@ -33,6 +37,7 @@ import { MAX_ATTRIBUTE_VALUE_CHAR_LENGTH } from './privacy'
import type { ElementsScrollPositions } from './elementsScrollPositions'
import { createElementsScrollPositions } from './elementsScrollPositions'
import type { ShadowRootCallBack, ShadowRootsController } from './shadowRootsController'
import type { WithAdoptedStyleSheets } from './browser.types'

const DEFAULT_CONFIGURATION = {} as RumConfiguration

Expand Down Expand Up @@ -86,6 +91,7 @@ describe('serializeNodeWithId', () => {
jasmine.objectContaining({ type: NodeType.DocumentType, name: 'html', publicId: '', systemId: '' }),
jasmine.objectContaining({ type: NodeType.Element, tagName: 'html' }),
],
adoptedStyleSheets: undefined,
id: jasmine.any(Number) as unknown as number,
})
})
Expand Down Expand Up @@ -437,6 +443,7 @@ describe('serializeNodeWithId', () => {
isShadowRoot: true,
childNodes: [],
id: jasmine.any(Number) as unknown as number,
adoptedStyleSheets: undefined,
},
],
id: jasmine.any(Number) as unknown as number,
Expand Down Expand Up @@ -468,6 +475,7 @@ describe('serializeNodeWithId', () => {
{
type: NodeType.DocumentFragment,
isShadowRoot: true,
adoptedStyleSheets: undefined,
childNodes: [
{
type: NodeType.Element,
Expand Down Expand Up @@ -684,6 +692,39 @@ describe('serializeDocumentNode handles', function testAllowDomTree() {
}
})

describe('with dynamic stylesheet', () => {
let isolatedDom: IsolatedDom

beforeEach(() => {
isolatedDom = createIsolatedDom()
})

afterEach(() => {
isolatedDom.clear()
})

it('serializes a document with adoptedStyleSheets', () => {
if (!isAdoptedStyleSheetsSupported()) {
pending('no adoptedStyleSheets support')
}
const styleSheet = new isolatedDom.window.CSSStyleSheet()
styleSheet.insertRule('div { width: 100%; }')
;(isolatedDom.document as WithAdoptedStyleSheets).adoptedStyleSheets = [styleSheet]
expect(serializeDocument(isolatedDom.document, DEFAULT_CONFIGURATION, DEFAULT_SERIALIZATION_CONTEXT)).toEqual({
type: NodeType.Document,
childNodes: [jasmine.objectContaining({ type: NodeType.Element, tagName: 'html' })],
adoptedStyleSheets: [
{
cssRules: ['div { width: 100%; }'],
disabled: undefined,
media: undefined,
},
],
id: jasmine.any(Number) as unknown as number,
})
})
})

it('a masked DOM Document itself is still serialized ', () => {
const serializeOptionsMask: SerializeOptions = {
...DEFAULT_OPTIONS,
Expand All @@ -692,6 +733,7 @@ describe('serializeDocumentNode handles', function testAllowDomTree() {
expect(serializeDocumentNode(document, serializeOptionsMask)).toEqual({
type: NodeType.Document,
childNodes: serializeChildNodes(document, serializeOptionsMask),
adoptedStyleSheets: undefined,
})
})

Expand Down
6 changes: 6 additions & 0 deletions packages/rum/src/domain/record/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ import {
setSerializedNodeId,
getElementInputValue,
switchToAbsoluteUrl,
serializeStyleSheets,
} from './serializationUtils'
import { forEach } from './utils'
import type { ElementsScrollPositions } from './elementsScrollPositions'
import type { ShadowRootsController } from './shadowRootsController'
import type { WithAdoptedStyleSheets } from './browser.types'

// Those values are the only one that can be used when inheriting privacy levels from parent to
// children during serialization, since HIDDEN and IGNORE shouldn't serialize their children. This
Expand Down Expand Up @@ -125,6 +127,7 @@ export function serializeDocumentNode(document: Document, options: SerializeOpti
return {
type: NodeType.Document,
childNodes: serializeChildNodes(document, options),
adoptedStyleSheets: serializeStyleSheets((document as WithAdoptedStyleSheets).adoptedStyleSheets),
}
}

Expand Down Expand Up @@ -155,6 +158,9 @@ function serializeDocumentFragmentNode(
type: NodeType.DocumentFragment,
childNodes,
isShadowRoot,
adoptedStyleSheets: isShadowRoot
? serializeStyleSheets((element as WithAdoptedStyleSheets).adoptedStyleSheets)
: undefined,
}
}

Expand Down
63 changes: 62 additions & 1 deletion test/e2e/scenario/recorder/shadowDom.scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import {
findElementWithTagName,
findFullSnapshot,
findIncrementalSnapshot,
findNode,
findTextContent,
findTextNode,
} from '@datadog/browser-rum/test/utils'

import type { EventRegistry } from '../../lib/framework'
import { flushEvents, createTest, bundleSetup, html } from '../../lib/framework'
import { browserExecute } from '../../lib/helpers/browser'
import { browserExecute, getBrowserName } from '../../lib/helpers/browser'

/** Will generate the following HTML
* ```html
Expand Down Expand Up @@ -80,6 +82,34 @@ const divShadowDom = `<script>
</script>
`

/** Will generate the following HTML
* ```html
* <iv-with-style>
* #shadow-root
* <div>toto</div>
*</iv-with-style>
ThibautGeriz marked this conversation as resolved.
Show resolved Hide resolved
*```
when called like `<div-with-style />`
*/
const divWithStyleShadowDom = `<script>
class DivWithStyle extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
const div = document.createElement("div");
div.textContent = 'toto'
this.shadowRoot.appendChild(div);
const styleSheet = new CSSStyleSheet();
styleSheet.insertRule('div { width: 100%; }')
this.shadowRoot.adoptedStyleSheets = [styleSheet]
}
}
window.customElements.define("div-with-style", DivWithStyle);
</script>
`

describe('recorder with shadow DOM', () => {
createTest('can record fullsnapshot with the detail inside the shadow root')
.withRum({ defaultPrivacyLevel: 'allow', enableExperimentalFeatures: ['record_shadow_dom'] })
Expand All @@ -104,6 +134,33 @@ describe('recorder with shadow DOM', () => {
expect(textNode?.textContent).toBe('toto')
})

if (isAdoptedStyleSheetsSupported()) {
createTest('can record fullsnapshot with adoptedStylesheet')
.withRum({ enableExperimentalFeatures: ['record_shadow_dom'] })
.withRumInit(initRumAndStartRecording)
.withSetup(bundleSetup)
.withBody(
html`
${divWithStyleShadowDom}
<div-with-style />
`
)
.run(async ({ serverEvents }) => {
await flushEvents()

expect(serverEvents.sessionReplay.length).toBe(1)

const fullSnapshot = findFullSnapshot(getFirstSegment(serverEvents))!
expect(fullSnapshot).toBeTruthy()
const shadowRoot = findNode(
fullSnapshot.data.node,
(node) => node.type === NodeType.DocumentFragment
) as DocumentFragmentNode
expect(shadowRoot.isShadowRoot).toBe(true)
expect(shadowRoot.adoptedStyleSheets).toEqual([{ cssRules: ['div { width: 100%; }'] }])
})
}

createTest('can apply privacy level set from outside or inside the shadow DOM')
.withRum({ defaultPrivacyLevel: 'allow', enableExperimentalFeatures: ['record_shadow_dom'] })
.withRumInit(initRumAndStartRecording)
Expand Down Expand Up @@ -238,3 +295,7 @@ async function getNodeInsideShadowDom(hostTag: string, selector: string) {
const host = await $(hostTag)
return host.shadow$(selector)
}

function isAdoptedStyleSheetsSupported() {
return ['edge', 'chrome'].includes(getBrowserName())
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 suggestion: ‏Could we check the adoptedStyleSheets property on document instead?
it would allow to automatically run this test on other browsers when available

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no because it would be in the runner (node) not in the targetted browser :(

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

browserExecute helper could execute this check on the browser side

Copy link
Contributor Author

@ThibautGeriz ThibautGeriz Jan 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, @BenoitZugmeyer you suggest that approach. Maybe you want to share your opinion here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to get the result out of the browser to the runner to stop the test?

Copy link
Member

@BenoitZugmeyer BenoitZugmeyer Jan 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit more annoying as it would be asynchronous, but we can try!

Note, instead of if (...) { createTest(...).run() } it would need to done in the .run() callback like:

.run(async () => {
  if (!await isAdoptedStyleSheetsSupported()) { pending('...') }
  ...
})

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BenoitZugmeyer How can I test that locally? By default it will run on Chrome up to date and be ok with the test but how can I test that it will be pending?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can try with a constant condition (ex: if (!await Promise.resolve(false)) { pending() }

About the condition specifically, I managed to run e2e tests in firefox locally, or you can run your tests on browserstack with the yarn test:e2e:bs with the right tokens as environment variable. Or simply push the change and see how it goes :)