Skip to content

Commit

Permalink
feat: Support shadow DOM recursive selectors inside cypress snapshots…
Browse files Browse the repository at this point in the history
… sent to protocol (#28823)

* chore: support new protocol structure for shadow DOM elements

* chore: capture scroll events inside shadow DOM that occur on shadow element scroll containers

* ignore scroll event listener test in webkit as fixture that uses CSSStyleSheet to help create scrollbox throws illegal constructor

* change structure of capture to be array of strings and assume shadowDom traversal similar to axe-core [run ci]

* fix issue where closed shawdow doms were causing application to crash [run ci]

* add actionability tests for protocol. Add synthetic input events in order to capture nested shadow DOM inputs

* build binary [run ci]

* remove actionability from driver [run ci]

* add changelog entry for feature, add serialization comments

* address comments from code review [run ci]

* remove unreachable code

* remove additional dead code

---------

Co-authored-by: Jennifer Shehane <[email protected]>
  • Loading branch information
AtofStryker and jennifer-shehane committed Mar 12, 2024
1 parent 854a649 commit 7a7a28f
Show file tree
Hide file tree
Showing 11 changed files with 951 additions and 73 deletions.
20 changes: 15 additions & 5 deletions .circleci/workflows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ mainBuildFilters: &mainBuildFilters
- develop
- /^release\/\d+\.\d+\.\d+$/
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- 'update-v8-snapshot-cache-on-develop'
- 'cacie/dep/electron-27'
- 'feat/protocol_shadow_dom_support'
- 'publish-binary'
- 'em/circle2'

# usually we don't build Mac app - it takes a long time
# but sometimes we want to really confirm we are doing the right thing
Expand All @@ -40,7 +43,9 @@ macWorkflowFilters: &darwin-workflow-filters
- equal: [ develop, << pipeline.git.branch >> ]
# 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: [ 'mschile/service_worker', << pipeline.git.branch >> ]
- equal: [ 'cacie/dep/electron-27', << 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+$/
value: << pipeline.git.branch >>
Expand All @@ -51,7 +56,9 @@ linuxArm64WorkflowFilters: &linux-arm64-workflow-filters
- equal: [ develop, << pipeline.git.branch >> ]
# 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: [ 'mschile/service_worker', << pipeline.git.branch >> ]
- equal: [ 'cacie/dep/electron-27', << pipeline.git.branch >> ]
- equal: [ 'feat/protocol_shadow_dom_support', << pipeline.git.branch >> ]
- equal: [ 'em/circle2', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
Expand All @@ -74,7 +81,10 @@ windowsWorkflowFilters: &windows-workflow-filters
- equal: [ develop, << pipeline.git.branch >> ]
# 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: [ 'mschile/service_worker', << pipeline.git.branch >> ]
- equal: [ 'cacie/dep/electron-27', << 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:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
Expand Down Expand Up @@ -144,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" != "mschile/service_worker" ]]; 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
3 changes: 2 additions & 1 deletion cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<!-- See the ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
## 13.6.7
## 13.7.0

_Released 3/12/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).
- Added TypeScript support for [Vue 2.7+](https://github.com/vuejs/vue/blob/main/CHANGELOG.md#270-2022-07-01). Addresses [#28591](https://github.com/cypress-io/cypress/issues/28591).

**Performance:**
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 => {
// 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.
*
* @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
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

4 comments on commit 7a7a28f

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 7a7a28f Mar 12, 2024

Choose a reason for hiding this comment

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

Circle has built the linux x64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/13.7.0/linux-x64/develop-7a7a28f9d6f414f363345d748439de064219330a/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 7a7a28f Mar 12, 2024

Choose a reason for hiding this comment

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

Circle has built the linux arm64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/13.7.0/linux-arm64/develop-7a7a28f9d6f414f363345d748439de064219330a/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 7a7a28f Mar 12, 2024

Choose a reason for hiding this comment

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

Circle has built the win32 x64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/13.7.0/win32-x64/develop-7a7a28f9d6f414f363345d748439de064219330a/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 7a7a28f Mar 12, 2024

Choose a reason for hiding this comment

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

Circle has built the darwin arm64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/13.7.0/darwin-arm64/develop-7a7a28f9d6f414f363345d748439de064219330a/cypress.tgz

Please sign in to comment.