+
+ beforeEach(() => {
+ logs = new Map()
+
+ cy.on('log:changed', (attrs, log) => {
+ logs.set(attrs.id, log)
+ })
+ })
+
+ it('.window()', () => {
+ cy.origin('http://foobar.com:3500', () => {
+ cy.window()
+ })
+
+ cy.shouldWithTimeout(() => {
+ const { consoleProps } = findCrossOriginLogs('window', logs, 'foobar.com')
+
+ expect(consoleProps.Command).to.equal('window')
+ expect(consoleProps.Yielded).to.be.null
+ })
+ })
+
+ it('.document()', () => {
+ cy.origin('http://foobar.com:3500', () => {
+ cy.document()
+ })
+
+ cy.shouldWithTimeout(() => {
+ const { consoleProps } = findCrossOriginLogs('document', logs, 'foobar.com')
+
+ expect(consoleProps.Command).to.equal('document')
+ expect(consoleProps.Yielded).to.be.null
+ })
+ })
+
+ it('.title()', () => {
+ cy.origin('http://foobar.com:3500', () => {
+ cy.title()
+ })
+
+ cy.shouldWithTimeout(() => {
+ const { consoleProps } = findCrossOriginLogs('title', logs, 'foobar.com')
+
+ expect(consoleProps.Command).to.equal('title')
+ expect(consoleProps.Yielded).to.equal('DOM Fixture')
+ })
+ })
+ })
})
diff --git a/packages/driver/cypress/integration/util/serialization_spec.ts b/packages/driver/cypress/integration/util/serialization_spec.ts
new file mode 100644
index 000000000000..b8c74725223b
--- /dev/null
+++ b/packages/driver/cypress/integration/util/serialization_spec.ts
@@ -0,0 +1,688 @@
+import { reifyDomElement, preprocessDomElement, preprocessLogLikeForSerialization, preprocessLogForSerialization, reifyLogFromSerialization } from '../../../src/util/serialization/log'
+
+describe('Log Serialization', () => {
+ const buildSnapshot = (innerSnapshotElement) => {
+ const mockSnapshot = document.createElement('body')
+
+ // populate some items into the mockSnapshot that would mimic what the DOM might actually look like, along with our inner snapshot element
+ const mockContainer = document.createElement('div')
+ const mockInnerHeader = document.createElement('h1')
+ const mockTextNode = document.createTextNode('Mock Snapshot Header')
+
+ mockInnerHeader.appendChild(mockTextNode)
+ mockContainer.appendChild(mockInnerHeader)
+ mockContainer.appendChild(innerSnapshotElement)
+
+ mockSnapshot.appendChild(mockContainer)
+
+ return mockSnapshot
+ }
+
+ it('preprocesses complex log-like data structures by preprocessing log DOM elements and table functions', () => {
+ const mockSpan = document.createElement('span')
+
+ mockSpan.innerHTML = 'click button'
+
+ const mockButton = document.createElement('button')
+
+ mockButton.appendChild(mockSpan)
+
+ const mockClickedElement = document.createElement('form')
+
+ mockClickedElement.appendChild(mockButton)
+ mockClickedElement.id = 'button-inside-a'
+
+ const mockSnapshot = buildSnapshot(mockClickedElement)
+
+ const mockSnapshots = ['before', 'after'].map((snapshotName) => {
+ return {
+ name: snapshotName,
+ htmlAttrs: {},
+ body: {
+ get: () => Cypress.$(mockSnapshot),
+ },
+ }
+ })
+
+ // mockLogAttrs should look just like log attributes that are emitted from log:changed/log:added events. This example is what a 'click' log may look like
+ const mockLogAttrs = {
+ $el: Cypress.$(mockClickedElement),
+ alias: undefined,
+ chainerId: 'mock-chainer-id',
+ consoleProps: {
+ ['Applied To']: mockClickedElement,
+ Command: 'click',
+ Coords: {
+ x: 100,
+ y: 50,
+ },
+ Options: undefined,
+ Yielded: undefined,
+ table: {
+ 1: () => {
+ return {
+ name: 'Mouse Events',
+ // NOTE: click data length is truncated for test readability
+ data: [
+ {
+ 'Active Modifiers': null,
+ 'Event Type': 'pointerover',
+ 'Prevented Default': null,
+ 'Stopped Propagation': null,
+ 'Target Element': mockClickedElement,
+ },
+ {
+ 'Active Modifiers': null,
+ 'Event Type': 'mouseover',
+ 'Prevented Default': null,
+ 'Stopped Propagation': null,
+ 'Target Element': mockClickedElement,
+ },
+ ],
+ }
+ },
+ },
+ },
+ coords: { top: 50, left: 50, topCenter: 100, leftCenter: 1000, x: 100, y: 50 },
+ ended: true,
+ err: undefined,
+ event: false,
+ highlightAttr: 'data-cypress-el',
+ hookId: 'r4',
+ id: 'mock-log-id',
+ instrument: 'command',
+ message: '',
+ name: 'click',
+ numElements: 1,
+ referencesAlias: undefined,
+ renderProps: {},
+ snapshots: mockSnapshots,
+ state: 'passed',
+ testCurrentRetry: 0,
+ testId: 'r4',
+ timeout: 4000,
+ type: 'child',
+ url: 'http://www.foobar.com',
+ viewportHeight: 660,
+ viewportWidth: 1000,
+ visible: true,
+ wallClockStartedAt: '2022-04-18T21:52:37.833Z',
+ }
+
+ const { consoleProps, snapshots, $el, ...logAttrs } = preprocessLogForSerialization(mockLogAttrs)
+
+ expect(logAttrs).to.deep.equal({
+ alias: undefined,
+ chainerId: 'mock-chainer-id',
+ coords: { top: 50, left: 50, topCenter: 100, leftCenter: 1000, x: 100, y: 50 },
+ ended: true,
+ err: undefined,
+ event: false,
+ highlightAttr: 'data-cypress-el',
+ hookId: 'r4',
+ id: 'mock-log-id',
+ instrument: 'command',
+ message: '',
+ name: 'click',
+ numElements: 1,
+ referencesAlias: undefined,
+ renderProps: {},
+ state: 'passed',
+ testCurrentRetry: 0,
+ testId: 'r4',
+ timeout: 4000,
+ type: 'child',
+ url: 'http://www.foobar.com',
+ viewportHeight: 660,
+ viewportWidth: 1000,
+ visible: true,
+ wallClockStartedAt: '2022-04-18T21:52:37.833Z',
+ })
+
+ expect($el).to.deep.equal([
+ {
+ attributes: {
+ id: 'button-inside-a',
+ },
+ innerHTML: '',
+ serializationKey: 'dom',
+ tagName: 'FORM',
+ },
+ ])
+
+ expect(consoleProps).to.deep.equal({
+ ['Applied To']: {
+ attributes: {
+ id: 'button-inside-a',
+ },
+ innerHTML: '',
+ serializationKey: 'dom',
+ tagName: 'FORM',
+ },
+ Command: 'click',
+ Coords: {
+ x: 100,
+ y: 50,
+ },
+ Options: undefined,
+ Yielded: undefined,
+ table: {
+ 1: {
+ serializationKey: 'function',
+ value: {
+ name: 'Mouse Events',
+ data: [
+ {
+ 'Active Modifiers': null,
+ 'Event Type': 'pointerover',
+ 'Prevented Default': null,
+ 'Stopped Propagation': null,
+ 'Target Element': {
+ attributes: {
+ id: 'button-inside-a',
+ },
+ innerHTML: '',
+ serializationKey: 'dom',
+ tagName: 'FORM',
+ },
+ },
+ {
+ 'Active Modifiers': null,
+ 'Event Type': 'mouseover',
+ 'Prevented Default': null,
+ 'Stopped Propagation': null,
+ 'Target Element': {
+ attributes: {
+ id: 'button-inside-a',
+ },
+ innerHTML: '',
+ serializationKey: 'dom',
+ tagName: 'FORM',
+ },
+ },
+ ],
+ },
+ },
+ },
+ })
+
+ expect(snapshots).to.deep.equal([
+ {
+ name: 'before',
+ htmlAttrs: {},
+ styles: {},
+ body: {
+ get: {
+ serializationKey: 'function',
+ value: [{
+ attributes: {},
+ innerHTML: `Mock Snapshot Header
`,
+ serializationKey: 'dom',
+ tagName: 'BODY',
+ }],
+ },
+ },
+ },
+ {
+ name: 'after',
+ htmlAttrs: {},
+ styles: {},
+ body: {
+ get: {
+ serializationKey: 'function',
+ value: [{
+ attributes: {},
+ innerHTML: `Mock Snapshot Header
`,
+ serializationKey: 'dom',
+ tagName: 'BODY',
+ }],
+ },
+ },
+ },
+ ])
+ })
+
+ it('reifies complex log-like data structures by reifying serialized DOM elements and table functions back into native data types, respectively', () => {
+ // this should log identical to the test output above from what a preprocessed click log looks like after postMessage()
+ const mockPreprocessedLogAttrs = {
+ $el: [
+ {
+ attributes: {
+ id: 'button-inside-a',
+ },
+ innerHTML: '',
+ serializationKey: 'dom',
+ tagName: 'FORM',
+ },
+ ],
+ alias: undefined,
+ chainerId: 'mock-chainer-id',
+ consoleProps: {
+ ['Applied To']: {
+ attributes: {
+ id: 'button-inside-a',
+ },
+ innerHTML: '',
+ serializationKey: 'dom',
+ tagName: 'FORM',
+ },
+ Command: 'click',
+ Coords: {
+ x: 100,
+ y: 50,
+ },
+ Options: undefined,
+ Yielded: undefined,
+ table: {
+ 1: {
+ serializationKey: 'function',
+ value: {
+ name: 'Mouse Events',
+ // NOTE: click data length is truncated for test readability
+ data: [
+ {
+ 'Active Modifiers': null,
+ 'Event Type': 'pointerover',
+ 'Prevented Default': null,
+ 'Stopped Propagation': null,
+ 'Target Element': {
+ attributes: {
+ id: 'button-inside-a',
+ },
+ innerHTML: '',
+ serializationKey: 'dom',
+ tagName: 'FORM',
+ },
+ },
+ {
+ 'Active Modifiers': null,
+ 'Event Type': 'mouseover',
+ 'Prevented Default': null,
+ 'Stopped Propagation': null,
+ 'Target Element': {
+ attributes: {
+ id: 'button-inside-a',
+ },
+ innerHTML: '',
+ serializationKey: 'dom',
+ tagName: 'FORM',
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ coords: { top: 50, left: 50, topCenter: 100, leftCenter: 1000, x: 100, y: 50 },
+ ended: true,
+ err: undefined,
+ event: false,
+ highlightAttr: 'data-cypress-el',
+ hookId: 'r4',
+ id: 'mock-log-id',
+ instrument: 'command',
+ message: '',
+ name: 'click',
+ numElements: 1,
+ referencesAlias: undefined,
+ renderProps: {},
+ snapshots: [
+ {
+ name: 'before',
+ htmlAttrs: {},
+ styles: {},
+ body: {
+ get: {
+ serializationKey: 'function',
+ value: [{
+ attributes: {},
+ innerHTML: `Mock Snapshot Header
`,
+ serializationKey: 'dom',
+ tagName: 'BODY',
+ }],
+ },
+ },
+ },
+ {
+ name: 'after',
+ htmlAttrs: {},
+ styles: {},
+ body: {
+ get: {
+ serializationKey: 'function',
+ value: [{
+ attributes: {},
+ innerHTML: `Mock Snapshot Header
`,
+ serializationKey: 'dom',
+ tagName: 'BODY',
+ }],
+ },
+ },
+ },
+ ],
+ state: 'passed',
+ testCurrentRetry: 0,
+ testId: 'r4',
+ timeout: 4000,
+ type: 'child',
+ url: 'http://www.foobar.com',
+ viewportHeight: 660,
+ viewportWidth: 1000,
+ visible: true,
+ wallClockStartedAt: '2022-04-18T21:52:37.833Z',
+ }
+
+ const { consoleProps, snapshots, $el, ...logAttrs } = reifyLogFromSerialization(mockPreprocessedLogAttrs)
+
+ expect(logAttrs).to.deep.equal({
+ alias: undefined,
+ chainerId: 'mock-chainer-id',
+ coords: { top: 50, left: 50, topCenter: 100, leftCenter: 1000, x: 100, y: 50 },
+ ended: true,
+ err: undefined,
+ event: false,
+ highlightAttr: 'data-cypress-el',
+ hookId: 'r4',
+ id: 'mock-log-id',
+ instrument: 'command',
+ message: '',
+ name: 'click',
+ numElements: 1,
+ referencesAlias: undefined,
+ renderProps: {},
+ state: 'passed',
+ testCurrentRetry: 0,
+ testId: 'r4',
+ timeout: 4000,
+ type: 'child',
+ url: 'http://www.foobar.com',
+ viewportHeight: 660,
+ viewportWidth: 1000,
+ visible: true,
+ wallClockStartedAt: '2022-04-18T21:52:37.833Z',
+ })
+
+ expect($el.jquery).to.be.ok
+ expect($el.length).to.equal(1)
+ expect($el[0]).to.be.instanceOf(HTMLFormElement)
+ expect($el[0].id).to.equal('button-inside-a')
+ expect($el[0].textContent).to.equal('click button')
+
+ // most of the consoleProps logic is tested in the e2e/multi-domain folder. focus in this test will be mostly snapshot serialization
+ expect(consoleProps['Applied To']).to.be.instanceOf(HTMLFormElement)
+ expect(consoleProps['Applied To']).to.have.property('id').that.equals('button-inside-a')
+ expect(consoleProps['Applied To']).to.have.property('textContent').that.equals('click button')
+
+ expect(consoleProps.table).to.have.property('1')
+ expect(consoleProps.table[1]).to.be.a('function')
+
+ expect(snapshots).to.have.lengthOf(2)
+
+ expect(snapshots[0]).to.have.property('name').that.equals('before')
+ expect(snapshots[0]).to.have.property('htmlAttrs').that.deep.equals({})
+ // styles should now live in the CSS map after a snapshot is processed through createSnapshot and snapshots should exist in document map
+ expect(snapshots[0]).to.not.have.property('styles')
+ expect(snapshots[0]).to.have.property('body').that.has.property('get').that.is.a('function')
+
+ const snapshotBodyBefore = snapshots[0].body.get()
+
+ expect(snapshotBodyBefore.length).to.equal(1)
+
+ expect(snapshotBodyBefore[0]).to.be.instanceOf(HTMLBodyElement)
+ // verify to some degree that the reified elements above can be matched into the snapshot
+ expect(snapshotBodyBefore[0].querySelector('form#button-inside-a')).to.be.instanceOf(HTMLFormElement)
+
+ expect(snapshots[1]).to.have.property('name').that.equals('after')
+ expect(snapshots[1]).to.have.property('htmlAttrs').that.deep.equals({})
+ expect(snapshots[1]).to.not.have.property('styles')
+ expect(snapshots[1]).to.have.property('body').that.has.property('get').that.is.a('function')
+
+ const snapshotBodyAfter = snapshots[1].body.get()
+
+ expect(snapshotBodyAfter.length).to.equal(1)
+
+ expect(snapshotBodyAfter[0]).to.be.instanceOf(HTMLBodyElement)
+ // verify to some degree that the reified elements above can be matched into the snapshot
+ expect(snapshotBodyAfter[0].querySelector('form#button-inside-a')).to.be.instanceOf(HTMLFormElement)
+ })
+
+ // purpose of these 'DOM Elements' tests is to give a very basic understanding of how DOM element serialization works in the log serializer
+ context('DOM Elements- preprocesses/reifies a given DOM element with stateful', () => {
+ context('input', () => {
+ it('preprocess', () => {
+ const inputElement = document.createElement('input')
+
+ inputElement.type = 'text'
+ inputElement.value = 'foo'
+ inputElement.setAttribute('data-cy', 'bar')
+
+ const snapshot = buildSnapshot(inputElement)
+
+ snapshot.setAttribute('foo', 'bar')
+
+ const preprocessedSnapshot = preprocessDomElement(snapshot)
+
+ expect(preprocessedSnapshot).to.have.property('tagName').that.equals('BODY')
+ expect(preprocessedSnapshot).to.have.property('serializationKey').that.equals('dom')
+ expect(preprocessedSnapshot).to.have.property('attributes').that.deep.equals({
+ foo: 'bar',
+ })
+
+ expect(preprocessedSnapshot).to.have.property('innerHTML').that.equals(`Mock Snapshot Header
`)
+ })
+
+ it('reifies', () => {
+ const preprocessedSnapshot = {
+ tagName: 'BODY',
+ serializationKey: 'dom',
+ attributes: {
+ foo: 'bar',
+ },
+ innerHTML: `Mock Snapshot Header
`,
+ }
+
+ const reifiedSnapshot = reifyDomElement(preprocessedSnapshot)
+
+ expect(reifiedSnapshot).to.be.instanceOf(HTMLBodyElement)
+ expect(reifiedSnapshot.getAttribute('foo')).to.equal('bar')
+ expect(reifiedSnapshot.querySelector('input[type="text"][value="foo"][data-cy="bar"]')).to.be.instanceOf(HTMLInputElement)
+ })
+ })
+
+ context('select', () => {
+ it('preprocess', () => {
+ const selectElement = document.createElement('select')
+
+ selectElement.id = 'metasyntactic-variables'
+ selectElement.name = 'Metasyntactic Variables'
+
+ const options = ['Hank Hill', 'Buck Strickland', 'Donna', 'Old Donna'].map((val) => {
+ const option = document.createElement('option')
+
+ option.value = val
+
+ return option
+ })
+
+ options.forEach((option) => selectElement.appendChild(option))
+
+ selectElement.selectedIndex = 1
+
+ const snapshot = buildSnapshot(selectElement)
+
+ const preprocessedSnapshot = preprocessDomElement(snapshot)
+
+ expect(preprocessedSnapshot).to.have.property('tagName').that.equals('BODY')
+ expect(preprocessedSnapshot).to.have.property('serializationKey').that.equals('dom')
+ expect(preprocessedSnapshot).to.have.property('innerHTML').that.equals(`Mock Snapshot Header
`)
+ })
+
+ it('reifies', () => {
+ const preprocessedSnapshot = {
+ tagName: 'BODY',
+ serializationKey: 'dom',
+ attributes: {},
+ innerHTML: `Mock Snapshot Header
`,
+ }
+
+ const reifiedSnapshot = reifyDomElement(preprocessedSnapshot)
+
+ expect(reifiedSnapshot).to.be.instanceOf(HTMLBodyElement)
+ expect(reifiedSnapshot.querySelector('select#metasyntactic-variables option[selected]')).to.have.property('value').that.equals('Buck Strickland')
+ })
+ })
+
+ context('textarea', () => {
+ it('preprocess', () => {
+ const textAreaElement = document.createElement('textarea')
+
+ textAreaElement.rows = 4
+ textAreaElement.cols = 20
+ textAreaElement.value = 'Generic variable names that function as placeholders'
+
+ const snapshot = buildSnapshot(textAreaElement)
+
+ const preprocessedSnapshot = preprocessDomElement(snapshot)
+
+ expect(preprocessedSnapshot).to.have.property('tagName').that.equals('BODY')
+ expect(preprocessedSnapshot).to.have.property('serializationKey').that.equals('dom')
+ expect(preprocessedSnapshot).to.have.property('innerHTML').that.equals(`Mock Snapshot Header
`)
+ })
+
+ it('reifies', () => {
+ const preprocessedSnapshot = {
+ tagName: 'BODY',
+ serializationKey: 'dom',
+ attributes: {},
+ innerHTML: `Mock Snapshot Header
`,
+ }
+
+ const reifiedSnapshot = reifyDomElement(preprocessedSnapshot)
+
+ expect(reifiedSnapshot).to.be.instanceOf(HTMLBodyElement)
+ expect(reifiedSnapshot.querySelector('textarea[rows="4"]')).to.have.property('textContent').that.equals('Generic variable names that function as placeholders')
+ })
+ })
+
+ context('radio', () => {
+ it('preprocess', () => {
+ const formElement = document.createElement('form')
+
+ const radioInputs = ['foo', 'bar', 'baz'].map((val) => {
+ const radioInput = document.createElement('input')
+
+ radioInput.type = 'radio'
+ radioInput.value = val
+
+ return radioInput
+ })
+
+ radioInputs[1].checked = true
+
+ radioInputs.forEach((radioInput) => formElement.appendChild(radioInput))
+
+ const snapshot = buildSnapshot(formElement)
+
+ const preprocessedSnapshot = preprocessDomElement(snapshot)
+
+ expect(preprocessedSnapshot).to.have.property('tagName').that.equals('BODY')
+ expect(preprocessedSnapshot).to.have.property('serializationKey').that.equals('dom')
+ expect(preprocessedSnapshot).to.have.property('innerHTML').that.equals(`Mock Snapshot Header
`)
+ })
+
+ it('reifies', () => {
+ const preprocessedSnapshot = {
+ tagName: 'BODY',
+ serializationKey: 'dom',
+ attributes: {},
+ innerHTML: `Mock Snapshot Header
`,
+ }
+
+ const reifiedSnapshot = reifyDomElement(preprocessedSnapshot)
+
+ expect(reifiedSnapshot).to.be.instanceOf(HTMLBodyElement)
+ expect(reifiedSnapshot.querySelector('form input[value="bar"]')).to.have.property('checked').that.equals(true)
+ })
+ })
+
+ context('checkbox', () => {
+ it('preprocess', () => {
+ const formElement = document.createElement('form')
+
+ const checkboxInputs = ['foo', 'bar', 'bar'].map((val) => {
+ const checkboxInput = document.createElement('input')
+
+ checkboxInput.type = 'checkbox'
+ checkboxInput.value = val
+
+ return checkboxInput
+ })
+
+ checkboxInputs[1].checked = true
+
+ checkboxInputs.forEach((checkboxInput) => formElement.appendChild(checkboxInput))
+
+ const snapshot = buildSnapshot(formElement)
+
+ const preprocessedSnapshot = preprocessDomElement(snapshot)
+
+ expect(preprocessedSnapshot).to.have.property('tagName').that.equals('BODY')
+ expect(preprocessedSnapshot).to.have.property('serializationKey').that.equals('dom')
+ expect(preprocessedSnapshot).to.have.property('innerHTML').that.equals(`Mock Snapshot Header
`)
+ })
+
+ it('reifies', () => {
+ const preprocessedSnapshot = {
+ tagName: 'BODY',
+ serializationKey: 'dom',
+ attributes: {},
+ innerHTML: `"Mock Snapshot Header
"`,
+ }
+
+ const reifiedSnapshot = reifyDomElement(preprocessedSnapshot)
+
+ expect(reifiedSnapshot).to.be.instanceOf(HTMLBodyElement)
+ expect(reifiedSnapshot.querySelector('form input[value="bar"]')).to.have.property('checked').that.equals(true)
+ })
+ })
+ })
+
+ // purpose of these 'DOM Elements' tests is to give a very basic understanding of how DOM element serialization works in the log serializer
+ context('Functions', () => {
+ it('does NOT try to serialize a function unless `attemptToSerializeFunctions` is set to true', () => {
+ const serializedFunction = preprocessLogLikeForSerialization(() => 'foo')
+
+ expect(serializedFunction).to.be.null
+ })
+
+ it('Tries to serialize EXPLICIT/KNOWN serializable functions by setting `attemptToSerializeFunctions` to true', () => {
+ const functionContents = [
+ 'foo',
+ {
+ bar: 'baz',
+ },
+ document.createElement('html'),
+ ]
+
+ const myKnownSerializableFunction = () => functionContents
+
+ const serializedFunction = preprocessLogLikeForSerialization(myKnownSerializableFunction, true)
+
+ expect(serializedFunction).to.deep.equal({
+ serializationKey: 'function',
+ value: [
+ 'foo',
+ {
+ bar: 'baz',
+ },
+ {
+ tagName: 'HTML',
+ serializationKey: 'dom',
+ attributes: {},
+ innerHTML: '',
+ },
+ ],
+ })
+ })
+ })
+})
diff --git a/packages/driver/cypress/support/utils.js b/packages/driver/cypress/support/utils.js
index c358c3d657c8..8ae506576458 100644
--- a/packages/driver/cypress/support/utils.js
+++ b/packages/driver/cypress/support/utils.js
@@ -72,6 +72,20 @@ export const assertLogLength = (logs, expectedLength) => {
expect(logs.length).to.eq(expectedLength, `received ${logs.length} logs when we expected ${expectedLength}: [${receivedLogs}]`)
}
+export const findCrossOriginLogs = (consolePropCommand, logMap, matchingOrigin) => {
+ const matchedLogs = Array.from(logMap.values()).filter((log) => {
+ const props = log.get()
+
+ let consoleProps = _.isFunction(props?.consoleProps) ? props.consoleProps() : props?.consoleProps
+
+ return consoleProps.Command === consolePropCommand && props.id.includes(matchingOrigin)
+ })
+
+ const logAttrs = matchedLogs.map((log) => log.get())
+
+ return logAttrs.length === 1 ? logAttrs[0] : logAttrs
+}
+
export const attachListeners = (listenerArr) => {
return (els) => {
_.each(els, (el, elName) => {
@@ -94,6 +108,10 @@ const getAllFn = (...aliases) => {
)
}
+const shouldWithTimeout = (cb, timeout = 250) => {
+ cy.wrap({}, { timeout }).should(cb)
+}
+
export const keyEvents = [
'keydown',
'keyup',
@@ -120,6 +138,8 @@ export const expectCaret = (start, end) => {
Cypress.Commands.add('getAll', getAllFn)
+Cypress.Commands.add('shouldWithTimeout', shouldWithTimeout)
+
const chaiSubset = require('chai-subset')
chai.use(chaiSubset)
diff --git a/packages/driver/src/cy/snapshots.ts b/packages/driver/src/cy/snapshots.ts
index 6d8edd3a421b..9d0a05328101 100644
--- a/packages/driver/src/cy/snapshots.ts
+++ b/packages/driver/src/cy/snapshots.ts
@@ -5,6 +5,8 @@ import { create as createSnapshotsCSS } from './snapshots_css'
export const HIGHLIGHT_ATTR = 'data-cypress-el'
+export const FINAL_SNAPSHOT_NAME = 'final state'
+
export const create = ($$, state) => {
const snapshotsCss = createSnapshotsCSS($$, state)
const snapshotsMap = new WeakMap()
@@ -99,15 +101,20 @@ export const create = ($$, state) => {
}
const getStyles = (snapshot) => {
- const styleIds = snapshotsMap.get(snapshot)
+ const { ids, styles } = snapshotsMap.get(snapshot) || {}
- if (!styleIds) {
+ if (!ids && !styles) {
return {}
}
+ // If a cross origin processed snapshot, styles are directly added into the CSS map. Simply return them.
+ if (styles?.headStyles || styles?.bodyStyles) {
+ return styles
+ }
+
return {
- headStyles: snapshotsCss.getStylesByIds(styleIds.headStyleIds),
- bodyStyles: snapshotsCss.getStylesByIds(styleIds.bodyStyleIds),
+ headStyles: snapshotsCss.getStylesByIds(ids?.headStyleIds),
+ bodyStyles: snapshotsCss.getStylesByIds(ids?.bodyStyleIds),
}
}
@@ -119,20 +126,24 @@ export const create = ($$, state) => {
$body.find('script,link[rel="stylesheet"],style').remove()
const snapshot = {
- name: 'final state',
+ name: FINAL_SNAPSHOT_NAME,
htmlAttrs,
body: {
get: () => $body.detach(),
},
}
- snapshotsMap.set(snapshot, { headStyleIds, bodyStyleIds })
+ snapshotsMap.set(snapshot, {
+ ids: {
+ headStyleIds,
+ bodyStyleIds,
+ },
+ })
return snapshot
}
- const createSnapshot = (name, $elToHighlight) => {
- Cypress.action('cy:snapshot', name)
+ const createSnapshotBody = ($elToHighlight) => {
// create a unique selector for this el
// but only IF the subject is truly an element. For example
// we might be wrapping a primitive like "$([1, 2]).first()"
@@ -141,64 +152,89 @@ export const create = ($$, state) => {
// jQuery v3 runs in strict mode and throws an error if you attempt to set a property
// TODO: in firefox sometimes this throws a cross-origin access error
- try {
- const isJqueryElement = $dom.isElement($elToHighlight) && $dom.isJquery($elToHighlight)
+ const isJqueryElement = $dom.isElement($elToHighlight) && $dom.isJquery($elToHighlight)
- if (isJqueryElement) {
- ($elToHighlight as JQuery).attr(HIGHLIGHT_ATTR, 'true')
- }
+ if (isJqueryElement) {
+ ($elToHighlight as JQuery).attr(HIGHLIGHT_ATTR, 'true')
+ }
- // TODO: throw error here if cy is undefined!
-
- // cloneNode can actually trigger functions attached to custom elements
- // so we have to use importNode to clone the element
- // https://github.com/cypress-io/cypress/issues/7187
- // https://github.com/cypress-io/cypress/issues/1068
- // we import it to a transient document (snapshotDocument) so that there
- // are no side effects from cloning it. see below for how we re-attach
- // it to the AUT document
- // https://github.com/cypress-io/cypress/issues/8679
- // this can fail if snapshotting before the page has fully loaded,
- // so we catch this below and return null for the snapshot
- // https://github.com/cypress-io/cypress/issues/15816
- const $body = $$(snapshotDocument.importNode($$('body')[0], true))
-
- // for the head and body, get an array of all CSS,
- // whether it's links or style tags
- // if it's same-origin, it will get the actual styles as a string
- // it it's cross-origin, it will get a reference to the link's href
- const { headStyleIds, bodyStyleIds } = snapshotsCss.getStyleIds()
-
- // replaces iframes with placeholders
- replaceIframes($body)
-
- // remove tags we don't want in body
- $body.find('script,link[rel=\'stylesheet\'],style').remove()
-
- // here we need to figure out if we're in a remote manual environment
- // if so we need to stringify the DOM:
- // 1. grab all inputs / textareas / options and set their value on the element
- // 2. convert DOM to string: body.prop("outerHTML")
- // 3. send this string via websocket to our server
- // 4. server rebroadcasts this to our client and its stored as a property
-
- // its also possible for us to store the DOM string completely on the server
- // without ever sending it back to the browser (until its requests).
- // we could just store it in memory and wipe it out intelligently.
- // this would also prevent having to store the DOM structure on the client,
- // which would reduce memory, and some CPU operations
-
- // now remove it after we clone
- if (isJqueryElement) {
- ($elToHighlight as JQuery).removeAttr(HIGHLIGHT_ATTR)
- }
+ // TODO: throw error here if cy is undefined!
+
+ // cloneNode can actually trigger functions attached to custom elements
+ // so we have to use importNode to clone the element
+ // https://github.com/cypress-io/cypress/issues/7187
+ // https://github.com/cypress-io/cypress/issues/1068
+ // we import it to a transient document (snapshotDocument) so that there
+ // are no side effects from cloning it. see below for how we re-attach
+ // it to the AUT document
+ // https://github.com/cypress-io/cypress/issues/8679
+ // this can fail if snapshotting before the page has fully loaded,
+ // so we catch this below and return null for the snapshot
+ // https://github.com/cypress-io/cypress/issues/15816
+ const $body = $$(snapshotDocument.importNode($$('body')[0], true))
+ // for the head and body, get an array of all CSS,
+ // whether it's links or style tags
+ // if it's same-origin, it will get the actual styles as a string
+ // if it's cross-origin, it will get a reference to the link's href
+ const { headStyleIds, bodyStyleIds } = snapshotsCss.getStyleIds()
+
+ // replaces iframes with placeholders
+ replaceIframes($body)
+
+ // remove tags we don't want in body
+ $body.find('script,link[rel=\'stylesheet\'],style').remove()
+
+ // here we need to figure out if we're in a remote manual environment
+ // if so we need to stringify the DOM:
+ // 1. grab all inputs / textareas / options and set their value on the element
+ // 2. convert DOM to string: body.prop("outerHTML")
+ // 3. send this string via websocket to our server
+ // 4. server rebroadcasts this to our client and its stored as a property
+
+ // its also possible for us to store the DOM string completely on the server
+ // without ever sending it back to the browser (until its requests).
+ // we could just store it in memory and wipe it out intelligently.
+ // this would also prevent having to store the DOM structure on the client,
+ // which would reduce memory, and some CPU operations
+
+ // now remove it after we clone
+ if (isJqueryElement) {
+ ($elToHighlight as JQuery).removeAttr(HIGHLIGHT_ATTR)
+ }
+
+ const $htmlAttrs = getHtmlAttrs($$('html')[0])
+
+ return {
+ $body,
+ $htmlAttrs,
+ headStyleIds,
+ bodyStyleIds,
+ }
+ }
+
+ const reifySnapshotBody = (preprocessedSnapshot) => {
+ const $body = preprocessedSnapshot.body.get()
+ const $htmlAttrs = preprocessedSnapshot.htmlAttrs
+ const { headStyles, bodyStyles } = preprocessedSnapshot.styles
+
+ return {
+ $body,
+ $htmlAttrs,
+ headStyles,
+ bodyStyles,
+ }
+ }
+
+ const createSnapshot = (name, $elToHighlight, preprocessedSnapshot) => {
+ Cypress.action('cy:snapshot', name)
+
+ try {
+ const {
+ $body,
+ $htmlAttrs,
+ ...styleAttrs
+ } = preprocessedSnapshot ? reifySnapshotBody(preprocessedSnapshot) : createSnapshotBody($elToHighlight)
- // preserve attributes on the tag
- const htmlAttrs = getHtmlAttrs($$('html')[0])
- // the body we clone via importNode above is attached to a transient document
- // so that there are no side effects from cloning it. we only attach it back
- // to the AUT document at the last moment (when restoring the snapshot)
- // https://github.com/cypress-io/cypress/issues/8679
let attachedBody
const body = {
get: () => {
@@ -212,11 +248,38 @@ export const create = ($$, state) => {
const snapshot = {
name,
- htmlAttrs,
+ htmlAttrs: $htmlAttrs,
body,
}
- snapshotsMap.set(snapshot, { headStyleIds, bodyStyleIds })
+ const {
+ headStyleIds,
+ bodyStyleIds,
+ headStyles,
+ bodyStyles,
+ }: {
+ headStyleIds?: string[]
+ bodyStyleIds?: string[]
+ headStyles?: string[]
+ bodyStyles?: string[]
+ } = styleAttrs
+
+ if (headStyleIds && bodyStyleIds) {
+ snapshotsMap.set(snapshot, {
+ ids: {
+ headStyleIds,
+ bodyStyleIds,
+ },
+ })
+ } else if (headStyles && bodyStyles) {
+ // The Snapshot is being reified from cross origin. Get inline styles of reified snapshot.
+ snapshotsMap.set(snapshot, {
+ styles: {
+ headStyles,
+ bodyStyles,
+ },
+ })
+ }
return snapshot
} catch (e) {
diff --git a/packages/driver/src/cypress/log.ts b/packages/driver/src/cypress/log.ts
index 66a00e464af0..da408e891170 100644
--- a/packages/driver/src/cypress/log.ts
+++ b/packages/driver/src/cypress/log.ts
@@ -444,7 +444,7 @@ class Log {
this.obj = {
highlightAttr: HIGHLIGHT_ATTR,
numElements: $el.length,
- visible: $el.length === $el.filter(':visible').length,
+ visible: this.get('visible') ?? $el.length === $el.filter(':visible').length,
}
return this.set(this.obj, { silent: true })
@@ -505,8 +505,11 @@ class Log {
consoleObj[key] = _this.get('name')
+ // in the case a log is being recreated from the cross-origin spec bridge to the primary, consoleProps may be an Object
+ const consoleObjDefaults = _.isFunction(consoleProps) ? consoleProps.apply(this, args) : consoleProps
+
// merge in the other properties from consoleProps
- _.extend(consoleObj, consoleProps.apply(this, args))
+ _.extend(consoleObj, consoleObjDefaults)
// TODO: right here we need to automatically
// merge in "Yielded + Element" if there is an $el
diff --git a/packages/driver/src/multi-domain/communicator.ts b/packages/driver/src/multi-domain/communicator.ts
index 3d8df0e4b537..c9f8cfa3e97f 100644
--- a/packages/driver/src/multi-domain/communicator.ts
+++ b/packages/driver/src/multi-domain/communicator.ts
@@ -3,10 +3,13 @@ import { EventEmitter } from 'events'
import { preprocessConfig, preprocessEnv } from '../util/config'
import { preprocessForSerialization, reifySerializedError } from '../util/serialization'
import { $Location } from '../cypress/location'
+import { preprocessLogForSerialization, reifyLogFromSerialization, preprocessSnapshotForSerialization, reifySnapshotFromSerialization } from '../util/serialization/log'
const debug = debugFn('cypress:driver:multi-origin')
const CROSS_ORIGIN_PREFIX = 'cross:origin:'
+const LOG_EVENTS = [`${CROSS_ORIGIN_PREFIX}log:added`, `${CROSS_ORIGIN_PREFIX}log:changed`]
+const FINAL_SNAPSHOT_EVENT = `${CROSS_ORIGIN_PREFIX}final:snapshot:generated`
/**
* Primary Origin communicator. Responsible for sending/receiving events throughout
@@ -42,6 +45,16 @@ export class PrimaryOriginCommunicator extends EventEmitter {
this.crossOriginDriverWindows[data.originPolicy] = source as Window
}
+ // reify any logs coming back from the cross-origin spec bridges to serialize snapshot/consoleProp DOM elements as well as select functions.
+ if (LOG_EVENTS.includes(data?.event)) {
+ data.data = reifyLogFromSerialization(data.data as any)
+ }
+
+ // reify the final snapshot coming back from the secondary domain if requested by the runner.
+ if (FINAL_SNAPSHOT_EVENT === data?.event) {
+ data.data = reifySnapshotFromSerialization(data.data as any)
+ }
+
if (data?.data?.err) {
data.data.err = reifySerializedError(data.data.err, this.userInvocationStack as string)
}
@@ -171,13 +184,24 @@ export class SpecBridgeCommunicator extends EventEmitter {
*/
toPrimary (event: string, data?: Cypress.ObjectLike, options: { syncGlobals: boolean } = { syncGlobals: false }) {
const { originPolicy } = $Location.create(window.location.href)
+ const eventName = `${CROSS_ORIGIN_PREFIX}${event}`
+
+ // Preprocess logs before sending through postMessage() to attempt to serialize some DOM nodes and functions.
+ if (LOG_EVENTS.includes(eventName)) {
+ data = preprocessLogForSerialization(data as any)
+ }
+
+ // If requested by the runner, preprocess the final snapshot before sending through postMessage() to attempt to serialize the DOM body of the snapshot.
+ if (FINAL_SNAPSHOT_EVENT === eventName) {
+ data = preprocessSnapshotForSerialization(data as any)
+ }
debug('<= to Primary ', event, data, originPolicy)
if (options.syncGlobals) this.syncGlobalsToPrimary()
this.handleSubjectAndErr(data, (data: Cypress.ObjectLike) => {
window.top?.postMessage({
- event: `${CROSS_ORIGIN_PREFIX}${event}`,
+ event: eventName,
data,
originPolicy,
}, '*')
diff --git a/packages/driver/src/multi-domain/cypress.ts b/packages/driver/src/multi-domain/cypress.ts
index ad485f391412..5a8d8cfb9425 100644
--- a/packages/driver/src/multi-domain/cypress.ts
+++ b/packages/driver/src/multi-domain/cypress.ts
@@ -6,10 +6,12 @@ import '../config/lodash'
import $Cypress from '../cypress'
import { $Cy } from '../cypress/cy'
+import { $Location } from '../cypress/location'
import $Commands from '../cypress/commands'
import { create as createLog } from '../cypress/log'
import { bindToListeners } from '../cy/listeners'
import { handleOriginFn } from './domain_fn'
+import { FINAL_SNAPSHOT_NAME } from '../cy/snapshots'
import { handleLogs } from './events/logs'
import { handleSocketEvents } from './events/socket'
import { handleSpecWindowEvents } from './events/spec_window'
@@ -30,6 +32,18 @@ const createCypress = () => {
setup(config, env)
})
+ Cypress.specBridgeCommunicator.on('generate:final:snapshot', (snapshotUrl: string) => {
+ const currentAutOriginPolicy = cy.state('autOrigin')
+ const requestedSnapshotUrlLocation = $Location.create(snapshotUrl)
+
+ if (requestedSnapshotUrlLocation.originPolicy === currentAutOriginPolicy) {
+ // if true, this is the correct specbridge to take the snapshot and send it back
+ const finalSnapshot = cy.createSnapshot(FINAL_SNAPSHOT_NAME)
+
+ Cypress.specBridgeCommunicator.toPrimary('final:snapshot:generated', finalSnapshot)
+ }
+ })
+
Cypress.specBridgeCommunicator.toPrimary('bridge:ready')
}
diff --git a/packages/driver/src/multi-domain/events/logs.ts b/packages/driver/src/multi-domain/events/logs.ts
index 1a7973c375d6..c686c5004020 100644
--- a/packages/driver/src/multi-domain/events/logs.ts
+++ b/packages/driver/src/multi-domain/events/logs.ts
@@ -1,12 +1,10 @@
-import { LogUtils } from '../../cypress/log'
-
export const handleLogs = (Cypress: Cypress.Cypress) => {
const onLogAdded = (attrs) => {
- Cypress.specBridgeCommunicator.toPrimary('log:added', LogUtils.getDisplayProps(attrs))
+ Cypress.specBridgeCommunicator.toPrimary('log:added', attrs)
}
const onLogChanged = (attrs) => {
- Cypress.specBridgeCommunicator.toPrimary('log:changed', LogUtils.getDisplayProps(attrs))
+ Cypress.specBridgeCommunicator.toPrimary('log:changed', attrs)
}
Cypress.on('log:added', onLogAdded)
diff --git a/packages/driver/src/util/serialization.ts b/packages/driver/src/util/serialization/index.ts
similarity index 91%
rename from packages/driver/src/util/serialization.ts
rename to packages/driver/src/util/serialization/index.ts
index 16d82f3e8bf7..32cfc3ef353e 100644
--- a/packages/driver/src/util/serialization.ts
+++ b/packages/driver/src/util/serialization/index.ts
@@ -1,7 +1,7 @@
import _ from 'lodash'
import structuredClonePonyfill from 'core-js-pure/actual/structured-clone'
-import $stackUtils from '../cypress/stack_utils'
-import $errUtils from '../cypress/error_utils'
+import $stackUtils from '../../cypress/stack_utils'
+import $errUtils from '../../cypress/error_utils'
export const UNSERIALIZABLE = '__cypress_unserializable_value'
@@ -10,7 +10,7 @@ export const UNSERIALIZABLE = '__cypress_unserializable_value'
// @ts-ignore
const structuredCloneRef = window?.structuredClone || structuredClonePonyfill
-const isSerializableInCurrentBrowser = (value: any) => {
+export const isSerializableInCurrentBrowser = (value: any) => {
try {
structuredCloneRef(value)
@@ -24,6 +24,12 @@ const isSerializableInCurrentBrowser = (value: any) => {
return false
}
+ // In some instances of structuredClone, Bluebird promises are considered serializable, but can be very deep objects
+ // For ours needs, we really do NOT want to serialize these
+ if (value instanceof Cypress.Promise) {
+ return false
+ }
+
return true
} catch (e) {
return false
@@ -106,7 +112,8 @@ export const preprocessForSerialization = (valueToSanitize: { [key: string]:
// Even if native errors can be serialized through postMessage, many properties are omitted on structuredClone(), including prototypical hierarchy
// because of this, we preprocess native errors to objects and postprocess them once they come back to the primary origin
- if (_.isArray(valueToSanitize)) {
+ // see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Typed_arrays. This is important for commands like .selectFile() using buffer streams
+ if (_.isArray(valueToSanitize) || _.isTypedArray(valueToSanitize)) {
return _.map(valueToSanitize, preprocessForSerialization) as unknown as T
}
diff --git a/packages/driver/src/util/serialization/log.ts b/packages/driver/src/util/serialization/log.ts
new file mode 100644
index 000000000000..d8a19f0823c7
--- /dev/null
+++ b/packages/driver/src/util/serialization/log.ts
@@ -0,0 +1,425 @@
+import _ from 'lodash'
+import { isSerializableInCurrentBrowser, preprocessForSerialization } from './index'
+import $dom from '../../dom'
+
+interface PreprocessedHTMLElement {
+ tagName: string
+ attributes: { [key: string]: string }
+ innerHTML: string
+ serializationKey: 'dom'
+}
+
+interface PreprocessedFunction {
+ value: any
+ serializationKey: 'function'
+}
+
+/**
+ * Takes an HTMLElement that might be a for a given snapshot or any other element, likely pertaining to log consoleProps,
+ * on the page that needs to be preprocessed for serialization. The method here is to do a very shallow serialization,
+ * by trying to make the HTML as stateful as possible before preprocessing.
+ *
+ * @param {HTMLElement} props - an HTMLElement
+ * @returns {PreprocessedHTMLElement} a preprocessed element that can be fed through postMessage() that can be reified in the primary.
+ */
+export const preprocessDomElement = (props: HTMLElement) => {
+ const inputPreprocessArray = Array.from(props.querySelectorAll('input, textarea, select'))
+
+ // Since we serialize on innerHTML, we also need to account for the props element itself in the case it is an input, select, or textarea.
+ inputPreprocessArray.push(props)
+
+ // Hydrate values in the HTML copy so when serialized they show up correctly in snapshot.
+ // We do this by mapping certain properties to attributes that are not already reflected in the attributes map.
+ // Things like id, class, type, and others are reflected in the attribute map and do not need to be explicitly added.
+ inputPreprocessArray.forEach((el: any) => {
+ switch (el.type) {
+ case 'checkbox':
+ case 'radio':
+ if (el.checked) {
+ el.setAttribute('checked', '')
+ }
+
+ break
+ case 'select-one':
+ case 'select-multiple': {
+ const options = el.type === 'select-one' ? el.options : el.selectedOptions
+
+ if (el.selectedIndex !== -1) {
+ for (let option of options) {
+ if (option.selected) {
+ option.setAttribute('selected', 'true')
+ } else {
+ option.removeAttribute('selected')
+ }
+ }
+ }
+ }
+ break
+ case 'textarea': {
+ el.innerHTML = el.value
+ }
+ break
+ default:
+ if (el.value !== undefined) {
+ el.setAttribute('value', el.value)
+ }
+ }
+ })
+
+ const el: PreprocessedHTMLElement = {
+ tagName: props.tagName,
+ attributes: {},
+ innerHTML: props.innerHTML,
+ serializationKey: 'dom',
+ }
+
+ // get all attributes and classes off the element
+ props.getAttributeNames().forEach((attributeName) => {
+ el.attributes[attributeName] = props.getAttribute(attributeName) || ''
+ })
+
+ return el
+}
+
+/**
+ * Takes an PreprocessedHTMLElement that might represent a given snapshot or any other element that needs to be reified
+ * after postMessage() serialization. The method here is to do a very basic reification,
+ * attempting to create an element based off the PreprocessedHTMLElement tagName, and populating some basic state if applicable,
+ * such as element type, id, value, classes, attributes, etc.
+ *
+ * @param {PreprocessedHTMLElement} props - a preprocessed element that was fed through postMessage() that need to be reified in the primary.
+ * @returns {HTMLElement} a reified element, likely a log snapshot, $el, or consoleProp elements.
+ */
+export const reifyDomElement = (props: any) => {
+ const reifiedEl = document.createElement(props.tagName)
+
+ reifiedEl.innerHTML = props.innerHTML
+
+ Object.keys(props.attributes).forEach((attribute) => {
+ reifiedEl.setAttribute(attribute, props.attributes[attribute])
+ })
+
+ return reifiedEl
+}
+
+/**
+ * Attempts to preprocess an Object/Array by excluding unserializable values except for DOM elements and possible functions (if attemptToSerializeFunctions is true).
+ * DOM elements are processed to a serializable object via preprocessDomElement, and functions are serialized to an object with a value key containing their output contents.
+ *
+ * @param {any} props an Object/Array that needs to be preprocessed before being sent through postMessage().
+ * @param {boolean} [attemptToSerializeFunctions=false] - Whether or not the function should attempt to preprocess a function by invoking it.
+ * @returns
+ */
+export const preprocessObjectLikeForSerialization = (props, attemptToSerializeFunctions = false) => {
+ if (_.isArray(props)) {
+ return props.map((prop) => preprocessLogLikeForSerialization(prop, attemptToSerializeFunctions))
+ }
+
+ if (_.isPlainObject(props)) {
+ // only attempt to try and serialize dom elements and functions (if attemptToSerializeFunctions is set to true)
+ let objWithPossiblySerializableProps = _.pickBy(props, (value) => {
+ const isSerializable = isSerializableInCurrentBrowser(value)
+
+ if (!isSerializable && $dom.isDom(value) || _.isFunction(value) || _.isObject(value)) {
+ return true
+ }
+
+ return false
+ })
+
+ let objWithOnlySerializableProps = _.pickBy(props, (value) => isSerializableInCurrentBrowser(value))
+
+ // assign the properties we know we can serialize here
+ let preprocessed: any = preprocessForSerialization(objWithOnlySerializableProps)
+
+ // and attempt to serialize possibly unserializable props here and fail gracefully if unsuccessful
+ _.forIn(objWithPossiblySerializableProps, (value, key) => {
+ preprocessed[key] = preprocessLogLikeForSerialization(value, attemptToSerializeFunctions)
+ })
+
+ return preprocessed
+ }
+
+ return preprocessForSerialization(props)
+}
+
+/**
+ * Attempts to take an Object and reify it correctly. Most of this is handled by reifyLogLikeFromSerialization, with the exception here being DOM elements.
+ * DOM elements, if needed to match against the snapshot DOM, are defined as getters on the object to have their values calculated at request.
+ * This is important for certain log items, such as consoleProps, to be rendered correctly against the snapshot. Other DOM elements, such as snapshots, do not need to be matched
+ * against the current DOM and can be reified immediately. Since there is a potential need for object getters to exist within an array, arrays are wrapped in a proxy, with array indices
+ * proxied to the reified object or array, and other methods proxying to the preprocessed array (such as native array methods like map, foreach, etc...).
+ *
+ * @param {Object} props - a preprocessed Object/Array that was fed through postMessage() that need to be reified in the primary.
+ * @param {boolean} matchElementsAgainstSnapshotDOM - whether DOM elements within the Object/Array should be matched against
+ * @returns {Object|Proxy} - a reified version of the Object or Array (Proxy).
+ */
+export const reifyObjectLikeForSerialization = (props, matchElementsAgainstSnapshotDOM) => {
+ let reifiedObjectOrArray = {}
+
+ _.forIn(props, (value, key) => {
+ const val = reifyLogLikeFromSerialization(value, matchElementsAgainstSnapshotDOM)
+
+ if (val?.serializationKey === 'dom') {
+ if (matchElementsAgainstSnapshotDOM) {
+ // dynamically calculate the element (snapshot or otherwise).
+ // This is important for consoleProp/$el based properties on the log because it calculates the requested element AFTER the snapshot has been rendered into the AUT.
+ reifiedObjectOrArray = {
+ ...reifiedObjectOrArray,
+ get [key] () {
+ return val.reifyElement()
+ },
+ }
+ } else {
+ // The DOM element in question is something like a snapshot. It can be reified immediately
+ reifiedObjectOrArray[key] = val.reifyElement()
+ }
+ } else {
+ reifiedObjectOrArray[key] = reifyLogLikeFromSerialization(value, matchElementsAgainstSnapshotDOM)
+ }
+ })
+
+ // NOTE: transforms arrays into objects to have defined getters for DOM elements, and proxy back to that object via an ES6 Proxy.
+ if (_.isArray(props)) {
+ // if an array, map the array to our special getter object.
+ return new Proxy(reifiedObjectOrArray, {
+ get (target, name) {
+ return target[name] || props[name]
+ },
+ })
+ }
+
+ // otherwise, just returned the object with our special getter
+ return reifiedObjectOrArray
+}
+
+/**
+ * Attempts to take a generic data structure that is log-like and preprocess them for serialization. This generic may contain properties that are either
+ * a) unserializable entirely
+ * b) unserializable natively but can be processed to a serializable form (DOM elements or Functions)
+ * c) serializable
+ *
+ * DOM elements are preprocessed via some key properties
+ * (attributes, classes, ids, tagName, value) including their innerHTML. Before the innerHTML is captured, inputs are traversed to set their stateful value
+ * inside the DOM element. This is crucial for body copy snapshots that are being sent to the primary domain to make the snapshot 'stateful'. Functions, if
+ * explicitly stated, will be preprocessed with whatever value they return (assuming that value is serializable). If a value cannot be preprocessed for whatever reason,
+ * null is returned.
+ *
+ *
+ * NOTE: this function recursively calls itself to preprocess a log
+ *
+ * @param {any} props a generic variable that represents a value that needs to be preprocessed before being sent through postMessage().
+ * @param {boolean} [attemptToSerializeFunctions=false] - Whether or not the function should attempt to preprocess a function by invoking it. USE WITH CAUTION!
+ * @returns {any} the serializable version of the generic.
+ */
+export const preprocessLogLikeForSerialization = (props, attemptToSerializeFunctions = false) => {
+ try {
+ if ($dom.isDom(props)) {
+ if (props.length !== undefined && $dom.isJquery(props)) {
+ const serializableArray: any[] = []
+
+ // in the case we are dealing with a jQuery array, preprocess to a native array to nuke any prevObject(s) or unserializable values
+ props.each((key) => serializableArray.push(preprocessLogLikeForSerialization(props[key], attemptToSerializeFunctions)))
+
+ return serializableArray
+ }
+
+ // otherwise, preprocess the element to an object with pertinent DOM properties
+ const serializedDom = preprocessDomElement(props)
+
+ return serializedDom
+ }
+
+ /**
+ * When preprocessing a log, there might be certain functions we want to attempt to serialize.
+ * One of these instances is the 'table' key in consoleProps, which has contents that CAN be serialized.
+ * If there are other functions that have serializable contents, the invoker/developer will need to be EXPLICIT
+ * in what needs serialization. Otherwise, functions should NOT be serialized.
+ */
+ if (_.isFunction(props)) {
+ if (attemptToSerializeFunctions) {
+ return {
+ value: preprocessLogLikeForSerialization(props(), attemptToSerializeFunctions),
+ serializationKey: 'function',
+ } as PreprocessedFunction
+ }
+
+ return null
+ }
+
+ if (_.isObject(props)) {
+ return preprocessObjectLikeForSerialization(props, attemptToSerializeFunctions)
+ }
+
+ return preprocessForSerialization(props)
+ } catch (e) {
+ return null
+ }
+}
+
+/**
+ * Attempts to take in a preprocessed/serialized log-like attributes and reify them. DOM elements are lazily calculated via
+ * getter properties on an object. If these DOM elements are in an array, the array is defined as an ES6 proxy that
+ * ultimately proxies to these getter objects. Functions, if serialized, are rewrapped. If a value cannot be reified for whatever reason,
+ * null is returned.
+ *
+ * This is logLike because there is a need outside of logs, such as in the iframe-model in the runner.
+ * to serialize DOM elements, such as the final snapshot upon request.
+ *
+ * NOTE: this function recursively calls itself to reify a log
+ *
+ * @param {any} props - a generic variable that represents a value that has been preprocessed and sent through postMessage() and needs to be reified.
+ * @param {boolean} matchElementsAgainstSnapshotDOM - Whether or not the element should be reconstructed lazily
+ * against the currently rendered DOM (usually against a rendered snapshot) or should be completely recreated from scratch (common with snapshots as they will replace the DOM)
+ * @returns {any} the reified version of the generic.
+ */
+export const reifyLogLikeFromSerialization = (props, matchElementsAgainstSnapshotDOM = true) => {
+ try {
+ if (props?.serializationKey === 'dom') {
+ props.reifyElement = function () {
+ let reifiedElement
+
+ // If the element needs to be matched against the currently rendered DOM. This is useful when analyzing consoleProps or $el in a log
+ // where elements need to be evaluated LAZILY after the snapshot is attached to the page.
+ // this option is set to false when reifying snapshots, since they will be replacing the current DOM when the user interacts with said snapshot.
+ if (matchElementsAgainstSnapshotDOM) {
+ const attributes = Object.keys(props.attributes).map((attribute) => {
+ return `[${attribute}="${props.attributes[attribute]}"]`
+ }).join('')
+
+ const selector = `${props.tagName}${attributes}`
+
+ reifiedElement = Cypress.$(selector)
+
+ if (reifiedElement.length) {
+ return reifiedElement.length > 1 ? reifiedElement : reifiedElement[0]
+ }
+ }
+
+ // if the element couldn't be found, return a synthetic copy that doesn't actually exist on the page
+ return reifyDomElement(props)
+ }
+
+ return props
+ }
+
+ if (props?.serializationKey === 'function') {
+ const reifiedFunctionData = reifyLogLikeFromSerialization(props.value, matchElementsAgainstSnapshotDOM)
+
+ return () => reifiedFunctionData
+ }
+
+ if (_.isObject(props)) {
+ return reifyObjectLikeForSerialization(props, matchElementsAgainstSnapshotDOM)
+ }
+
+ return props
+ } catch (e) {
+ return null
+ }
+}
+
+/**
+ * Preprocess a snapshot to a serializable form before piping them over through postMessage().
+ * This method is also used by a spec bridge on request if a 'final state' snapshot is requested outside that of the primary domain
+ *
+ * @param {any} snapshot - a snapshot matching the same structure that is returned from cy.createSnapshot.
+ * @returns a serializable form of a snapshot, including a serializable with styles
+ */
+export const preprocessSnapshotForSerialization = (snapshot) => {
+ try {
+ const preprocessedSnapshot = preprocessLogLikeForSerialization(snapshot, true)
+
+ if (!preprocessedSnapshot.body.get) {
+ return null
+ }
+
+ preprocessedSnapshot.styles = cy.getStyles(snapshot)
+
+ return preprocessedSnapshot
+ } catch (e) {
+ return null
+ }
+}
+
+/**
+ * Reifies a snapshot from the serializable from to an actual HTML body snapshot that exists in the primary document.
+ * @param {any} snapshot - a snapshot that has been preprocessed and sent through post message and needs to be reified in the primary.
+ * @returns the reified snapshot that exists in the primary document
+ */
+export const reifySnapshotFromSerialization = (snapshot) => {
+ snapshot.body = reifyLogLikeFromSerialization(snapshot.body, false)
+
+ return cy.createSnapshot(snapshot.name, null, snapshot)
+}
+
+/**
+ * Sanitizes the log messages going to the primary domain before piping them to postMessage().
+ * This is designed to function as an extension of preprocessForSerialization, but also attempts to serialize DOM elements,
+ * as well as functions if explicitly stated.
+ *
+ * DOM elements are serialized with their outermost properties (attributes, classes, ids, tagName) including their innerHTML.
+ * DOM Traversal serialization is not possible with larger html bodies and will likely cause a stack overflow.
+ *
+ * Functions are serialized when explicitly state (ex: table in consoleProps).
+ * NOTE: If not explicitly handling function serialization for a given property, the property will be set to null
+ *
+ * @param logAttrs raw log attributes passed in from either a log:changed or log:added event
+ * @returns a serializable form of the log, including attempted serialization of DOM elements and Functions (if explicitly stated)
+ */
+export const preprocessLogForSerialization = (logAttrs) => {
+ let { snapshots, ...logAttrsRest } = logAttrs
+
+ const preprocessed = preprocessLogLikeForSerialization(logAttrsRest)
+
+ if (preprocessed) {
+ if (snapshots) {
+ preprocessed.snapshots = snapshots.map((snapshot) => preprocessSnapshotForSerialization(snapshot))
+ }
+
+ if (logAttrs?.consoleProps?.table) {
+ preprocessed.consoleProps.table = preprocessLogLikeForSerialization(logAttrs.consoleProps.table, true)
+ }
+ }
+
+ return preprocessed
+}
+
+/**
+ * Redefines log messages being received in the primary domain before sending them out through the event-manager.
+ *
+ * Efforts here include importing captured snapshots from the spec bridge into the primary snapshot document, importing inline
+ * snapshot styles into the snapshot css map, and reconstructing DOM elements and functions somewhat naively.
+ *
+ * To property render consoleProps/$el elements in snapshots or the console, DOM elements are lazily calculated via
+ * getter properties on an object. If these DOM elements are in an array, the array is defined as an ES6 proxy that
+ * ultimately proxies to these getter objects.
+ *
+ * The secret here is that consoleProp DOM elements needs to be reified at console printing runtime AFTER the serialized snapshot
+ * is attached to the DOM so the element can be located and displayed properly
+ *
+ * In most cases, the element can be queried by attributes that exist specifically on the element or by the `HIGHLIGHT_ATTR`. If that fails or does not locate an element,
+ * then a new element is created against the snapshot context. This element will NOT be found on the page, but will represent what the element
+ * looked like at the time of the snapshot.
+ *
+ * @param logAttrs serialized/preprocessed log attributes passed to the primary domain from a spec bridge
+ * @returns a reified version of what a log is supposed to look like in Cypress
+ */
+export const reifyLogFromSerialization = (logAttrs) => {
+ let { snapshots, ... logAttrsRest } = logAttrs
+
+ if (snapshots) {
+ snapshots = snapshots.filter((snapshot) => !!snapshot).map((snapshot) => reifySnapshotFromSerialization(snapshot))
+ }
+
+ const reified = reifyLogLikeFromSerialization(logAttrsRest)
+
+ if (reified.$el && reified.$el.length) {
+ // Make sure $els are jQuery Arrays to keep what is expected in the log.
+ reified.$el = Cypress.$(reified.$el.map((el) => el))
+ }
+
+ reified.snapshots = snapshots
+
+ return reified
+}
diff --git a/packages/driver/types/internal-types.d.ts b/packages/driver/types/internal-types.d.ts
index 82a0754ded3e..4c470a0f8b9a 100644
--- a/packages/driver/types/internal-types.d.ts
+++ b/packages/driver/types/internal-types.d.ts
@@ -46,6 +46,8 @@ declare namespace Cypress {
isAnticipatingCrossOriginResponseFor: IStability['isAnticipatingCrossOriginResponseFor']
fail: (err: Error, options:{ async?: boolean }) => Error
getRemoteLocation: ILocation['getRemoteLocation']
+ createSnapshot: ISnapshots['createSnapshot']
+ getStyles: ISnapshots['getStyles']
}
interface Cypress {
diff --git a/packages/driver/types/spec-types.d.ts b/packages/driver/types/spec-types.d.ts
new file mode 100644
index 000000000000..c0ebdbfc6d2a
--- /dev/null
+++ b/packages/driver/types/spec-types.d.ts
@@ -0,0 +1,8 @@
+// NOTE: This is for internal Cypress spec types that exist in support/utils.js for testing convenience and do not ship with Cypress
+
+declare namespace Cypress {
+ interface Chainable {
+ getAll(...aliases: string[]): Chainable
+ shouldWithTimeout(cb: (subj: {}) => void, timeout?: number): Chainable
+ }
+}
diff --git a/packages/runner-ct/src/iframe/iframes.tsx b/packages/runner-ct/src/iframe/iframes.tsx
index 2c7771e1c9e9..2658c9a269b3 100644
--- a/packages/runner-ct/src/iframe/iframes.tsx
+++ b/packages/runner-ct/src/iframe/iframes.tsx
@@ -133,6 +133,8 @@ export const Iframes = namedObserver('Iframes', ({
restoreDom: autIframe.current.restoreDom,
highlightEl: autIframe.current.highlightEl,
detachDom: autIframe.current.detachDom,
+ isAUTSameOrigin: autIframe.current.doesAUTMatchTopOriginPolicy,
+ removeSrc: autIframe.current.removeSrcAttribute,
snapshotControls: (snapshotProps) => (
{
+ // If the test is over and the user enters interactive snapshot mode, do not add cross origin logs to the test runner.
+ if (Cypress.state('test')?.final) return
+
// Create a new local log representation of the cross origin log.
// It will be attached to the current command.
// We also keep a reference to it to update it in the future.
diff --git a/packages/runner-shared/src/iframe/aut-iframe.js b/packages/runner-shared/src/iframe/aut-iframe.js
index 96275c00221f..4a321ba4f31e 100644
--- a/packages/runner-shared/src/iframe/aut-iframe.js
+++ b/packages/runner-shared/src/iframe/aut-iframe.js
@@ -69,6 +69,40 @@ export class AutIframe {
return Cypress.cy.detachDom(this._contents())
}
+ /**
+ * If the AUT is cross origin relative to top, a security error is thrown and the method returns false
+ * If the AUT is cross origin relative to top and chromeWebSecurity is false, origins of the AUT and top need to be compared and returns false
+ * Otherwise, if top and the AUT match origins, the method returns true.
+ * If the AUT origin is "about://blank", that means the src attribute has been stripped off the iframe and is adhering to same origin policy
+ */
+ doesAUTMatchTopOriginPolicy = () => {
+ const Cypress = eventManager.getCypress()
+
+ if (!Cypress) return
+
+ try {
+ const { href: currentHref } = this.$iframe[0].contentWindow.document.location
+ const locationTop = Cypress.Location.create(window.location.href)
+ const locationAUT = Cypress.Location.create(currentHref)
+
+ return locationTop.originPolicy === locationAUT.originPolicy || locationAUT.originPolicy === 'about://blank'
+ } catch (err) {
+ if (err.name === 'SecurityError') {
+ return false
+ }
+
+ throw err
+ }
+ }
+
+ /**
+ * Removes the src attribute from the AUT iframe, resulting in 'about:blank' being loaded into the iframe
+ * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-src for more details
+ */
+ removeSrcAttribute = () => {
+ this.$iframe.removeAttr('src')
+ }
+
visitBlank = ({ type } = { type: null }) => {
return new Promise((resolve) => {
this.$iframe[0].src = 'about:blank'
@@ -91,6 +125,23 @@ export class AutIframe {
}
restoreDom = (snapshot) => {
+ if (!this.doesAUTMatchTopOriginPolicy()) {
+ /**
+ * A load event fires here when the src is removed (as does an unload event).
+ * This is equivalent to loading about:blank (see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-src).
+ * This doesn't resort in a log message being generated for a new page.
+ * In the event-manager code, we stop adding logs from other domains once the spec is finished.
+ */
+ this.$iframe.one('load', () => {
+ this.restoreDom(snapshot)
+ })
+
+ // The iframe is in a cross origin state. Remove the src attribute to adhere to same origin policy. NOTE: This should only be done ONCE.
+ this.removeSrcAttribute()
+
+ return
+ }
+
const Cypress = eventManager.getCypress()
const { headStyles, bodyStyles } = Cypress ? Cypress.cy.getStyles(snapshot) : {}
const { body, htmlAttrs } = snapshot
diff --git a/packages/runner-shared/src/iframe/iframe-model.js b/packages/runner-shared/src/iframe/iframe-model.js
index e2639ce6974c..fca3665fb22c 100644
--- a/packages/runner-shared/src/iframe/iframe-model.js
+++ b/packages/runner-shared/src/iframe/iframe-model.js
@@ -6,12 +6,14 @@ import { studioRecorder } from '../studio'
import { eventManager } from '../event-manager'
export class IframeModel {
- constructor ({ state, detachDom, restoreDom, highlightEl, snapshotControls }) {
+ constructor ({ state, detachDom, restoreDom, highlightEl, snapshotControls, isAUTSameOrigin, removeSrc }) {
this.state = state
this.detachDom = detachDom
this.restoreDom = restoreDom
this.highlightEl = highlightEl
this.snapshotControls = snapshotControls
+ this.isAUTSameOrigin = isAUTSameOrigin
+ this.removeSrc = removeSrc
this._reset()
}
@@ -217,6 +219,31 @@ export class IframeModel {
}
_storeOriginalState () {
+ if (!this.isAUTSameOrigin()) {
+ const Cypress = eventManager.getCypress()
+
+ /**
+ * This only happens if the AUT ends in a cross origin state that the primary doesn't have access to.
+ * In this case, the final snapshot request from the primary is sent out to the cross-origin spec bridges.
+ * The spec bridge that matches the origin policy will take a snapshot and send it back to the primary for the runner to store in originalState.
+ */
+ Cypress.primaryOriginCommunicator.toAllSpecBridges('generate:final:snapshot', this.state.url)
+ Cypress.primaryOriginCommunicator.once('final:snapshot:generated', (finalSnapshot) => {
+ this.originalState = {
+ body: finalSnapshot.body,
+ htmlAttrs: finalSnapshot.htmlAttrs,
+ snapshot: finalSnapshot,
+ url: this.state.url,
+ // TODO: use same attr for both runner and runner-ct states.
+ // these refer to the same thing - the viewport dimensions.
+ viewportWidth: this.state.width,
+ viewportHeight: this.state.height,
+ }
+ })
+
+ return
+ }
+
const finalSnapshot = this.detachDom()
if (!finalSnapshot) return
diff --git a/packages/runner/index.d.ts b/packages/runner/index.d.ts
index 918d244cd9e7..8e559dc28663 100644
--- a/packages/runner/index.d.ts
+++ b/packages/runner/index.d.ts
@@ -8,3 +8,4 @@
///
///
+///
diff --git a/packages/runner/src/iframe/iframes.jsx b/packages/runner/src/iframe/iframes.jsx
index cf5407378eef..f5aea2a75673 100644
--- a/packages/runner/src/iframe/iframes.jsx
+++ b/packages/runner/src/iframe/iframes.jsx
@@ -109,6 +109,8 @@ export default class Iframes extends Component {
restoreDom: this.autIframe.restoreDom,
highlightEl: this.autIframe.highlightEl,
detachDom: this.autIframe.detachDom,
+ isAUTSameOrigin: this.autIframe.doesAUTMatchTopOriginPolicy,
+ removeSrc: this.autIframe.removeSrcAttribute,
snapshotControls: (snapshotProps) => (