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

feat: Support shadow DOM recursive selectors inside cypress snapshots sent to protocol #28823

Merged
merged 19 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8841df4
chore: support new protocol structure for shadow DOM elements
AtofStryker Jan 29, 2024
1de9e20
chore: capture scroll events inside shadow DOM that occur on shadow e…
AtofStryker Jan 29, 2024
be438d9
ignore scroll event listener test in webkit as fixture that uses CSSS…
AtofStryker Jan 30, 2024
2142b94
change structure of capture to be array of strings and assume shadowD…
AtofStryker Feb 14, 2024
938b9fe
fix issue where closed shawdow doms were causing application to crash…
AtofStryker Feb 15, 2024
618c53e
add actionability tests for protocol. Add synthetic input events in o…
AtofStryker Feb 26, 2024
1dc0e9b
build binary [run ci]
AtofStryker Feb 26, 2024
c86a741
remove actionability from driver [run ci]
AtofStryker Feb 26, 2024
efb5181
add changelog entry for feature, add serialization comments
AtofStryker Feb 28, 2024
9132ea1
address comments from code review [run ci]
AtofStryker Feb 28, 2024
067095e
remove unreachable code
AtofStryker Feb 29, 2024
53b9b24
remove additional dead code
AtofStryker Feb 29, 2024
bdc3dd1
Merge branch 'develop' of github.com:cypress-io/cypress into feat/pro…
AtofStryker Mar 5, 2024
7e1592a
Merge branch 'develop' into feat/protocol_shadow_dom_support
AtofStryker Mar 8, 2024
625fb3c
Merge branch 'develop' into feat/protocol_shadow_dom_support
jennifer-shehane Mar 9, 2024
6c47f56
Merge branch 'develop' of github.com:cypress-io/cypress into feat/pro…
AtofStryker Mar 11, 2024
b1d94f3
Merge branch 'feat/protocol_shadow_dom_support' of github.com:cypress…
AtofStryker Mar 11, 2024
5a4d073
Merge branch 'develop' of github.com:cypress-io/cypress into feat/pro…
AtofStryker Mar 11, 2024
baf229b
Merge branch 'develop' of github.com:cypress-io/cypress into feat/pro…
AtofStryker Mar 12, 2024
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
10 changes: 5 additions & 5 deletions .circleci/workflows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ mainBuildFilters: &mainBuildFilters
- /^release\/\d+\.\d+\.\d+$/
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- 'cacie/dep/electron-27'
- 'fix/force_colors_on_verify'
- 'feat/protocol_shadow_dom_support'
- 'publish-binary'
- 'em/circle2'

Expand All @@ -44,7 +44,7 @@ macWorkflowFilters: &darwin-workflow-filters
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'cacie/dep/electron-27', << pipeline.git.branch >> ]
- equal: [ 'fix/force_colors_on_verify', << pipeline.git.branch >> ]
- equal: [ 'feat/protocol_shadow_dom_support', << pipeline.git.branch >> ]
- equal: [ 'ryanm/fix/service-worker-capture', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
Expand All @@ -57,7 +57,7 @@ linuxArm64WorkflowFilters: &linux-arm64-workflow-filters
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'cacie/dep/electron-27', << pipeline.git.branch >> ]
- equal: [ 'fix/force_colors_on_verify', << pipeline.git.branch >> ]
- equal: [ 'feat/protocol_shadow_dom_support', << pipeline.git.branch >> ]
- equal: [ 'em/circle2', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
Expand All @@ -82,7 +82,7 @@ windowsWorkflowFilters: &windows-workflow-filters
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'cacie/dep/electron-27', << pipeline.git.branch >> ]
- equal: [ 'fix/force_colors_on_verify', << pipeline.git.branch >> ]
- equal: [ 'feat/protocol_shadow_dom_support', << pipeline.git.branch >> ]
- equal: [ 'lerna-optimize-tasks', << pipeline.git.branch >> ]
- equal: [ 'mschile/mochaEvents_win_sep', << pipeline.git.branch >> ]
- matches:
Expand Down Expand Up @@ -154,7 +154,7 @@ commands:
name: Set environment variable to determine whether or not to persist artifacts
command: |
echo "Setting SHOULD_PERSIST_ARTIFACTS variable"
echo 'if ! [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "publish-binary" && "$CIRCLE_BRANCH" != "fix/force_colors_on_verify" && "$CIRCLE_BRANCH" != "cacie/dep/electron-27" ]]; then
echo 'if ! [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "publish-binary" && "$CIRCLE_BRANCH" != "feat/protocol_shadow_dom_support" && "$CIRCLE_BRANCH" != "cacie/dep/electron-27" ]]; then
export SHOULD_PERSIST_ARTIFACTS=true
fi' >> "$BASH_ENV"
# You must run `setup_should_persist_artifacts` command and be using bash before running this command
Expand Down
6 changes: 5 additions & 1 deletion cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
<!-- See the ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
## 13.6.7
## 13.7.0

_Released 2/27/2024 (PENDING)_

**Features:**

- Added shadow DOM snapshot support within Test Replay in order to highlight elements correctly within the Cypress reporter. Addressed in [#28823](/~https://github.com/cypress-io/cypress/pull/28823).

**Bugfixes:**

- Changed RequestBody type to allow for boolean and null literals to be passed as body values. [#28789](/~https://github.com/cypress-io/cypress/issues/28789)
Expand Down
25 changes: 25 additions & 0 deletions packages/driver/cypress/e2e/cy/snapshot.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,31 @@ describe('driver/src/cy/snapshots', () => {
expect(name).to.equal('snapshot')
expect(timestamp).to.be.a('number')
})

it('captures shadow DOM selectors structure properly', {
protocolEnabled: true,
}, () => {
cy.visit('/fixtures/shadow-dom-type.html')
cy.window().then((win) => {
win.__cypressProtocolMetadata = { frameId: 'test-frame-id' }

cy.get('#shadow-dom-input', {
includeShadowDom: true,
}).then((shadowDomSlot) => {
const { elementsToHighlight, name, timestamp } = cy.createSnapshot('snapshot', shadowDomSlot)

expect(elementsToHighlight?.length).to.equal(1)
const elementToHighlight = elementsToHighlight[0]

expect(elementToHighlight.selector.length).to.equal(2)
expect(elementToHighlight.selector[0]).to.equal('#element')
expect(elementToHighlight.selector[1]).to.equal('#shadow-dom-input')
expect(elementToHighlight.frameId).to.equal('test-frame-id')
expect(name).to.equal('snapshot')
expect(timestamp).to.be.a('number')
})
})
})
})
})

Expand Down
1 change: 1 addition & 0 deletions packages/driver/cypress/fixtures/shadow-dom-type.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
this._shadow = this.attachShadow({mode: "open"});

const input = document.createElement("input");
input.id = 'shadow-dom-input'
this._shadow.appendChild(input);
}
}
Expand Down
149 changes: 126 additions & 23 deletions packages/driver/src/cy/snapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,89 @@ export const HIGHLIGHT_ATTR = 'data-cypress-el'

export const FINAL_SNAPSHOT_NAME = 'final state'

type SelectorNode = {
frameId?: string
selector: string
ownerDoc: Document | ShadowRoot
host?: SelectorNode
}

const returnShadowRootIfShadowDomNode = (node: Element): ShadowRoot | null => {
AtofStryker marked this conversation as resolved.
Show resolved Hide resolved
// the shadowRoot object property only lives on the node context OUTSIDE the shadow DOM, meaning that
// node.parentNode.host.shadowRoot works. Oddly, this is considered an instance of an Object and not
// a ShadowRoot, so checking for the shadowRoot on the host property is likely safe.
const isNodeShadowRoot = (n: any) => !!n?.host?.shadowRoot

let parent = node && node.parentNode

while (parent) {
if (isNodeShadowRoot(parent)) {
return parent as ShadowRoot
}

parent = parent.parentNode
}

return null
}

function findSelectorForElement (elem: Element, root: Document | ShadowRoot) {
// finder tries to find the shortest unique selector to an element,
// but since we are more concerned with speed, we set the threshold to 1 and maxNumberOfTries to 0
// @see /~https://github.com/antonmedv/finder/issues/75
return finder(elem, { root: root as unknown as Element, threshold: 1, maxNumberOfTries: 0 })
}

/**
* Builds a recursive structure of selectors in order to re-identify during Test Replay.
*
AtofStryker marked this conversation as resolved.
Show resolved Hide resolved
* @param elem - an HTML Element that lives within the shadow DOM or the regular DOM
* @returns SelectorNode if the selector can be discovered. For regular elements, this should only be one object deep, but for shadow DOM
* elements, the SelectorNode tree could be N levels deep until the root is discovered
*/
function constructElementSelectorTree (elem: Element): SelectorNode | undefined {
try {
const ownerDoc = elem.ownerDocument
const elWindow = ownerDoc.defaultView

if (elWindow === null) {
return undefined
}

// finder will return a string if it can find the selector.
// otherwise, an error will throw and we will fall back to shadowDom lookup.
const selector = findSelectorForElement(elem, ownerDoc)

const frameId = elWindow['__cypressProtocolMetadata']?.frameId

return { selector, frameId, ownerDoc: elem.ownerDocument, host: undefined }
} catch {
// the element may not always be found since it's possible for the element to be removed from the DOM
// Or maybe its in the shadow DOM.
// If it is a shadow DOM element, return the ShadowRoot as well to relate the node to the root document
try {
const shadowRoot = returnShadowRootIfShadowDomNode(elem)

// If we have a shadow DOM element, get the frameId and unique selector of the ShadowRoot
// see https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot
if (shadowRoot) {
// Look up the details of the shadowRoot to see which element the ShadowRoot is bound to, i.e. the host.
const hostDetails = constructElementSelectorTree(shadowRoot.host)

// look up our element inside the context of the ShadowRoot
const selectorFromShadowWorld = findSelectorForElement(elem, shadowRoot)

// gives us enough information to associate the shadow element to the ShadowRoot/host to reconstruct in Test Replay
return { selector: selectorFromShadowWorld, frameId: undefined, ownerDoc: shadowRoot, host: hostDetails }
}
} catch {
return undefined
}
}

return undefined
}

export const create = ($$: $Cy['$$'], state: StateFunc) => {
const snapshotsCss = createSnapshotsCSS($$, state)
const snapshotsMap = new WeakMap()
Expand Down Expand Up @@ -232,6 +315,47 @@ export const create = ($$: $Cy['$$'], state: StateFunc) => {
return $dom.isElement($el) && $dom.isJquery($el)
}

const buildSelectorArray = (el: HTMLElement) => {
// flatten selector to only include selector string values, which we can imply is a shadowRoot if other values exist in the tree
// this keeps the structure similar to axe-core
// @see /~https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#results-object -> target
const selectors: string[] | undefined = []
let frameId: string | undefined
const flattenElementSelectorTree = (el: SelectorNode | undefined): void => {
if (el) {
selectors.unshift(el?.selector)

if (el?.host) {
flattenElementSelectorTree(el.host)
} else {
frameId = el.frameId
}
}
}

const elToHighlight = constructElementSelectorTree(el)

flattenElementSelectorTree(elToHighlight)

let selector: string | string[] | undefined

switch (selectors.length) {
case 0:
selector = undefined
break
case 1:
selector = selectors[0]
break
default:
selector = selectors
}

return selector ? [{
selector,
frameId,
}] : []
}

const createSnapshot = (name, $elToHighlight, preprocessedSnapshot) => {
Cypress.action('cy:snapshot', name)
// when using cy.origin() and in a transitionary state, state('document')
Expand All @@ -254,34 +378,13 @@ export const create = ($$: $Cy['$$'], state: StateFunc) => {
name: string
AtofStryker marked this conversation as resolved.
Show resolved Hide resolved
timestamp: number
elementsToHighlight?: {
selector: string
selector: string | string []
frameId: string
}[]
} = { name, timestamp }

if (isJqueryElement($elToHighlight)) {
snapshot.elementsToHighlight = $dom.unwrap($elToHighlight).flatMap((el: HTMLElement) => {
try {
const ownerDoc = el.ownerDocument
const elWindow = ownerDoc.defaultView

if (elWindow === null) {
return []
}

// finder tries to find the shortest unique selector to an element,
// but since we are more concerned with speed, we set the threshold to 1 and maxNumberOfTries to 0
// @ts-expect-error because 'root' can be either Document or Element but is defined as Element
// @see /~https://github.com/antonmedv/finder/issues/75
const selector = finder(el, { root: ownerDoc, threshold: 1, maxNumberOfTries: 0 })
const frameId = elWindow['__cypressProtocolMetadata']?.frameId

return [{ selector, frameId }]
} catch {
// the element may not always be found since it's possible for the element to be removed from the DOM
return []
}
})
snapshot.elementsToHighlight = $dom.unwrap($elToHighlight).flatMap((el: HTMLElement) => buildSelectorArray(el))
}

Cypress.action('cy:protocol-snapshot')
Expand Down
Loading
Loading