diff --git a/packages/driver/cypress/fixtures/dom.html b/packages/driver/cypress/fixtures/dom.html index 1d3e241f2a46..6347924ceccf 100644 --- a/packages/driver/cypress/fixtures/dom.html +++ b/packages/driver/cypress/fixtures/dom.html @@ -437,8 +437,8 @@ not to be checked') + expect(assertionLogs[1].consoleProps.Message).to.equal('expected not to be disabled') + + assertionLogs.forEach(({ $el, consoleProps }) => { + expect($el.jquery).to.be.ok + + expect(consoleProps.Command).to.equal('assert') + expect(consoleProps.subject[0]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.subject[0]).to.have.property('value').that.equals('blue') + expect(consoleProps.subject[0].getAttribute('name')).to.equal('colors') + }) + }) + }) + }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_connectors.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_connectors.spec.ts index 732ce983827e..6b94fc87c3a8 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_connectors.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_connectors.spec.ts @@ -1,3 +1,5 @@ +import { findCrossOriginLogs } from '../../../../support/utils' + context('cy.origin connectors', () => { beforeEach(() => { cy.visit('/fixtures/multi-domain.html') @@ -43,4 +45,75 @@ context('cy.origin connectors', () => { }) }) }) + + context('#consoleProps', () => { + let logs: Map + + beforeEach(() => { + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + }) + + it('.its()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#by-id>input').its('length') + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps, $el } = findCrossOriginLogs('its', logs, 'foobar.com') + + expect($el.jquery).to.be.ok + + expect(consoleProps.Command).to.equal('its') + expect(consoleProps.Property).to.equal('.length') + expect(consoleProps.Yielded).to.equal(3) + + expect(consoleProps.Subject.length).to.equal(3) + + // make sure subject elements are indexed in the correct order + expect(consoleProps.Subject[0]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Subject[0]).to.have.property('id').that.equals('input') + + expect(consoleProps.Subject[1]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Subject[1]).to.have.property('id').that.equals('name') + + expect(consoleProps.Subject[2]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Subject[2]).to.have.property('id').that.equals('age') + }) + }) + + it('.invoke()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#button').invoke('text') + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps, $el } = findCrossOriginLogs('invoke', logs, 'foobar.com') + + expect($el.jquery).to.be.ok + + expect(consoleProps.Command).to.equal('invoke') + expect(consoleProps.Function).to.equal('.text()') + expect(consoleProps.Yielded).to.equal('button') + + expect(consoleProps.Subject).to.have.property('tagName').that.equals('BUTTON') + expect(consoleProps.Subject).to.have.property('id').that.equals('button') + }) + }) + }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_cookies.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_cookies.spec.ts index b068abb275db..ce088c50c29a 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_cookies.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_cookies.spec.ts @@ -1,3 +1,5 @@ +import { findCrossOriginLogs } from '../../../../support/utils' + context('cy.origin cookies', () => { beforeEach(() => { cy.visit('/fixtures/multi-domain.html') @@ -34,4 +36,142 @@ context('cy.origin cookies', () => { cy.getCookies().should('be.empty') }) }) + + context('#consoleProps', () => { + const { _ } = Cypress + let logs: Map + + beforeEach(() => { + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + }) + + it('.getCookie()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.getCookies().should('be.empty') + cy.setCookie('foo', 'bar') + cy.getCookie('foo') + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('getCookie', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('getCookie') + expect(consoleProps.Yielded).to.have.property('domain').that.includes('foobar.com') + expect(consoleProps.Yielded).to.have.property('expiry').that.is.a('number') + expect(consoleProps.Yielded).to.have.property('httpOnly').that.equals(false) + expect(consoleProps.Yielded).to.have.property('secure').that.equals(false) + expect(consoleProps.Yielded).to.have.property('name').that.equals('foo') + expect(consoleProps.Yielded).to.have.property('value').that.equals('bar') + expect(consoleProps.Yielded).to.have.property('path').that.is.a('string') + }) + }) + + it('.getCookies()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.getCookies().should('be.empty') + + cy.setCookie('foo', 'bar') + cy.getCookies() + }) + + cy.shouldWithTimeout(() => { + // get the last 'getCookies' command, which is the one we care about for this test + const allGetCookieLogs = findCrossOriginLogs('getCookies', logs, 'foobar.com') + + const { consoleProps } = allGetCookieLogs.pop() as any + + expect(consoleProps.Command).to.equal('getCookies') + expect(consoleProps['Num Cookies']).to.equal(1) + + // can't exactly assert on length() as this is a array proxy object + expect(consoleProps.Yielded.length).to.equal(1) + expect(consoleProps.Yielded[0]).to.have.property('expiry').that.is.a('number') + expect(consoleProps.Yielded[0]).to.have.property('httpOnly').that.equals(false) + expect(consoleProps.Yielded[0]).to.have.property('secure').that.equals(false) + expect(consoleProps.Yielded[0]).to.have.property('name').that.equals('foo') + expect(consoleProps.Yielded[0]).to.have.property('value').that.equals('bar') + expect(consoleProps.Yielded[0]).to.have.property('path').that.is.a('string') + }) + }) + + it('.setCookie()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.getCookies().should('be.empty') + + cy.setCookie('foo', 'bar') + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('setCookie', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('setCookie') + expect(consoleProps.Yielded).to.have.property('domain').that.includes('foobar.com') + expect(consoleProps.Yielded).to.have.property('expiry').that.is.a('number') + expect(consoleProps.Yielded).to.have.property('httpOnly').that.equals(false) + expect(consoleProps.Yielded).to.have.property('secure').that.equals(false) + expect(consoleProps.Yielded).to.have.property('name').that.equals('foo') + expect(consoleProps.Yielded).to.have.property('value').that.equals('bar') + expect(consoleProps.Yielded).to.have.property('path').that.is.a('string') + }) + }) + + it('.clearCookie()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.setCookie('foo', 'bar') + cy.getCookie('foo').should('not.be.null') + cy.clearCookie('foo') + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('clearCookie', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('clearCookie') + expect(consoleProps.Yielded).to.equal('null') + expect(consoleProps['Cleared Cookie']).to.have.property('domain').that.includes('foobar.com') + expect(consoleProps['Cleared Cookie']).to.have.property('expiry').that.is.a('number') + expect(consoleProps['Cleared Cookie']).to.have.property('httpOnly').that.equals(false) + expect(consoleProps['Cleared Cookie']).to.have.property('secure').that.equals(false) + expect(consoleProps['Cleared Cookie']).to.have.property('name').that.equals('foo') + expect(consoleProps['Cleared Cookie']).to.have.property('value').that.equals('bar') + expect(consoleProps['Cleared Cookie']).to.have.property('path').that.is.a('string') + }) + }) + + it('.clearCookies()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.setCookie('foo', 'bar') + cy.setCookie('faz', 'baz') + + cy.getCookies().should('have.length', 2) + cy.clearCookies() + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('clearCookies', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('clearCookies') + expect(consoleProps['Num Cookies']).to.equal(2) + + expect(consoleProps.Yielded).to.equal('null') + + expect(consoleProps['Cleared Cookies'].length).to.equal(2) + + expect(consoleProps['Cleared Cookies'][0]).to.have.property('name').that.equals('foo') + expect(consoleProps['Cleared Cookies'][0]).to.have.property('value').that.equals('bar') + + expect(consoleProps['Cleared Cookies'][1]).to.have.property('name').that.equals('faz') + expect(consoleProps['Cleared Cookies'][1]).to.have.property('value').that.equals('baz') + + _.forEach(consoleProps['Cleared Cookies'], (clearedCookie) => { + expect(clearedCookie).to.have.property('httpOnly').that.equals(false) + expect(clearedCookie).to.have.property('secure').that.equals(false) + expect(clearedCookie).to.have.property('path').that.is.a('string') + }) + }) + }) + }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_files.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_files.spec.ts index 611bcf83cdda..b741e55e2ac6 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_files.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_files.spec.ts @@ -1,3 +1,5 @@ +import { findCrossOriginLogs } from '../../../../support/utils' + context('cy.origin files', () => { beforeEach(() => { cy.visit('/fixtures/multi-domain.html') @@ -44,4 +46,51 @@ context('cy.origin files', () => { }) }) }) + + context('#consoleProps', () => { + let logs: Map + + beforeEach(() => { + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + }) + + it('.readFile()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.readFile('cypress/fixtures/example.json') + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('readFile', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('readFile') + expect(consoleProps['File Path']).to.include('cypress/fixtures/example.json') + expect(consoleProps.Contents).to.deep.equal({ example: true }) + }) + }) + + it('.writeFile()', () => { + cy.origin('http://foobar.com:3500', () => { + const contents = JSON.stringify({ foo: 'bar' }) + + cy.stub(Cypress, 'backend').resolves({ + contents, + filePath: 'foo.json', + }) + + cy.writeFile('foo.json', contents) + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('writeFile', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('writeFile') + expect(consoleProps['File Path']).to.equal('foo.json') + expect(consoleProps.Contents).to.equal('{"foo":"bar"}') + }) + }) + }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_local_storage.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_local_storage.spec.ts index 7996d9aed2d9..4ca617ef20d3 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_local_storage.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_local_storage.spec.ts @@ -1,3 +1,5 @@ +import { findCrossOriginLogs } from '../../../../support/utils' + context('cy.origin local storage', () => { beforeEach(() => { cy.visit('/fixtures/multi-domain.html') @@ -17,4 +19,34 @@ context('cy.origin local storage', () => { }) }) }) + + context('#consoleProps', () => { + let logs: Map + + beforeEach(() => { + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + }) + + it('.clearLocalStorage()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.window().then((win) => { + win.localStorage.setItem('foo', 'bar') + expect(win.localStorage.getItem('foo')).to.equal('bar') + }) + + cy.clearLocalStorage() + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('clearLocalStorage', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('clearLocalStorage') + expect(consoleProps.Yielded).to.be.null + }) + }) + }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_location.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_location.spec.ts index ba4b9a9b5b6e..cb920a1daa55 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_location.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_location.spec.ts @@ -1,3 +1,5 @@ +import { findCrossOriginLogs } from '../../../../support/utils' + context('cy.origin location', () => { beforeEach(() => { cy.visit('/fixtures/multi-domain.html') @@ -24,4 +26,68 @@ context('cy.origin location', () => { cy.url().should('equal', 'http://www.foobar.com:3500/fixtures/multi-domain-secondary.html') }) }) + + context('#consoleProps', () => { + let logs: Map + + beforeEach(() => { + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + }) + + it('.hash()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.hash() + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('hash', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('hash') + }) + }) + + it('.location()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.location() + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('location', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('location') + + expect(consoleProps.Yielded).to.have.property('auth').that.is.a('string') + expect(consoleProps.Yielded).to.have.property('authObj').that.is.undefined + expect(consoleProps.Yielded).to.have.property('hash').that.is.a('string') + expect(consoleProps.Yielded).to.have.property('host').that.is.a('string') + expect(consoleProps.Yielded).to.have.property('hostname').that.is.a('string') + expect(consoleProps.Yielded).to.have.property('href').that.is.a('string') + expect(consoleProps.Yielded).to.have.property('origin').that.is.a('string') + expect(consoleProps.Yielded).to.have.property('originPolicy').that.is.a('string') + expect(consoleProps.Yielded).to.have.property('pathname').that.is.a('string') + expect(consoleProps.Yielded).to.have.property('port').that.is.a('string') + expect(consoleProps.Yielded).to.have.property('protocol').that.is.a('string') + expect(consoleProps.Yielded).to.have.property('search').that.is.a('string') + expect(consoleProps.Yielded).to.have.property('superDomain').that.is.a('string') + }) + }) + + it('.url()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.url() + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('url', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('url') + + expect(consoleProps.Yielded).to.equal('http://www.foobar.com:3500/fixtures/multi-domain-secondary.html') + }) + }) + }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_misc.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_misc.spec.ts index beca1cf6cf05..0b5c075c23d8 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_misc.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_misc.spec.ts @@ -1,3 +1,5 @@ +import { findCrossOriginLogs } from '../../../../support/utils' + context('cy.origin misc', () => { beforeEach(() => { cy.visit('/fixtures/multi-domain.html') @@ -56,12 +58,146 @@ context('cy.origin misc', () => { cy.task('return:arg', 'works').should('eq', 'works') }) }) + + context('#consoleProps', () => { + let logs: Map + + beforeEach(() => { + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + }) + + it('.exec()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.exec('echo foobar') + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('exec', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('exec') + expect(consoleProps['Shell Used']).to.be.undefined + expect(consoleProps.Yielded).to.have.property('code').that.equals(0) + expect(consoleProps.Yielded).to.have.property('stderr').that.equals('') + expect(consoleProps.Yielded).to.have.property('stdout').that.equals('foobar') + }) + }) + + it('.focused()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#button').click().focused() + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('focused', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('focused') + expect(consoleProps.Elements).to.equal(1) + expect(consoleProps.Yielded).to.have.property('tagName').that.equals('BUTTON') + expect(consoleProps.Yielded).to.have.property('id').that.equals('button') + }) + }) + + it('.wrap()', () => { + cy.origin('http://foobar.com:3500', () => { + const arr = ['foo', 'bar', 'baz'] + + cy.wrap(arr).spread((foo, bar, baz) => { + expect(foo).to.equal('foo') + expect(bar).to.equal('bar') + expect(baz).to.equal('baz') + }) + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('wrap', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('wrap') + expect(consoleProps.Yielded[0]).to.equal('foo') + expect(consoleProps.Yielded[1]).to.equal('bar') + expect(consoleProps.Yielded[2]).to.equal('baz') + }) + }) + + it('.debug()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#button').debug() + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('debug', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('debug') + expect(consoleProps.Yielded).to.have.property('tagName').that.equals('BUTTON') + expect(consoleProps.Yielded).to.have.property('id').that.equals('button') + }) + }) + + it('.pause()', () => { + cy.origin('http://foobar.com:3500', () => { + const afterPaused = new Promise((resolve) => { + cy.once('paused', () => { + Cypress.emit('resume:all') + resolve() + }) + }) + + cy.pause().wrap({}).should('deep.eq', {}) + // pause is a noop in run mode, so only wait for it if in open mode + if (Cypress.config('isInteractive')) { + cy.wrap(afterPaused) + } + }) + + cy.shouldWithTimeout(() => { + if (Cypress.config('isInteractive')) { + // if `isInteractive`, the .pause() will NOT show up in the command log in this case. Essentially a no-op. + return + } + + const { consoleProps } = findCrossOriginLogs('pause', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('pause') + expect(consoleProps.Yielded).to.be.undefined + }) + }) + + it('.task()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.task('return:arg', 'works') + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('task', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('task') + expect(consoleProps.Yielded).to.equal('works') + expect(consoleProps.arg).to.equal('works') + expect(consoleProps.task).to.equal('return:arg') + }) + }) + }) }) it('verifies number of cy commands', () => { // @ts-ignore - // remove 'getAll' command since it's a custom command we add for our own testing and not an actual cy command - const actualCommands = Cypress._.reject(Object.keys(cy.commandFns), (command) => command === 'getAll') + // remove 'getAll' and 'shouldWithTimeout' commands since they are custom commands we added for our own testing and are not actual cy commands + const actualCommands = Cypress._.reject(Object.keys(cy.commandFns), (command) => command === 'getAll' || command === 'shouldWithTimeout') const expectedCommands = [ 'check', 'uncheck', 'click', 'dblclick', 'rightclick', 'focus', 'blur', 'hover', 'scrollIntoView', 'scrollTo', 'select', 'selectFile', 'submit', 'type', 'clear', 'trigger', 'as', 'ng', 'should', 'and', 'clock', 'tick', 'spread', 'each', 'then', diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_navigation.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_navigation.spec.ts index e6bc322cdb45..bc95ec846cc6 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_navigation.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_navigation.spec.ts @@ -1,4 +1,5 @@ const { stripIndent } = require('common-tags') +import { findCrossOriginLogs } from '../../../../support/utils' context('cy.origin navigation', () => { it('.go()', () => { @@ -496,4 +497,78 @@ context('cy.origin navigation', () => { cy.location('pathname').should('equal', '/fixtures/dom.html') }) }) + + context('#consoleProps', () => { + let logs: Map + + beforeEach(() => { + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + }) + + it('.go()', () => { + cy.visit('/fixtures/multi-domain.html') + cy.get('a[data-cy="cross-origin-secondary-link"]').click() + + cy.origin('http://foobar.com:3500', () => { + cy.visit('http://www.foobar.com:3500/fixtures/dom.html') + + cy.go('back') + }) + + cy.shouldWithTimeout(() => { + const { consoleProps, ...attrs } = findCrossOriginLogs('go', logs, 'foobar.com') + + expect(attrs.name).to.equal('go') + expect(attrs.message).to.equal('back') + + expect(consoleProps.Command).to.equal('go') + expect(consoleProps.Yielded).to.be.null + }) + }) + + it('.reload()', () => { + cy.visit('/fixtures/multi-domain.html') + cy.get('a[data-cy="dom-link"]').click() + + cy.origin('http://foobar.com:3500', () => { + cy.reload() + }) + + cy.shouldWithTimeout(() => { + const { consoleProps, ...attrs } = findCrossOriginLogs('reload', logs, 'foobar.com') + + expect(attrs.name).to.equal('reload') + expect(attrs.message).to.equal('') + + expect(consoleProps.Command).to.equal('reload') + expect(consoleProps.Yielded).to.be.null + }) + }) + + it('visit()', () => { + cy.visit('/fixtures/multi-domain.html') + + cy.origin('http://foobar.com:3500', () => { + cy.visit('http://www.foobar.com:3500/fixtures/multi-domain-secondary.html') + + cy.get('[data-cy="dom-check"]').should('have.text', 'From a secondary origin') + }) + + cy.shouldWithTimeout(() => { + const { consoleProps, ...attrs } = findCrossOriginLogs('visit', logs, 'foobar.com') + + expect(attrs.name).to.equal('visit') + expect(attrs.message).to.equal('http://www.foobar.com:3500/fixtures/multi-domain-secondary.html') + + expect(consoleProps.Command).to.equal('visit') + expect(consoleProps).to.have.property('Cookies Set').that.is.an('object') + expect(consoleProps).to.have.property('Redirects').that.is.an('object') + expect(consoleProps).to.have.property('Resolved Url').that.equals('http://www.foobar.com:3500/fixtures/multi-domain-secondary.html') + }) + }) + }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_network_requests.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_network_requests.spec.ts index 1afecedf3cb8..dfe3e7f3df52 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_network_requests.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_network_requests.spec.ts @@ -1,3 +1,5 @@ +import { findCrossOriginLogs } from '../../../../support/utils' + context('cy.origin network requests', () => { beforeEach(() => { cy.visit('/fixtures/multi-domain.html') @@ -30,4 +32,43 @@ context('cy.origin network requests', () => { }) }) }) + + context('#consoleProps', () => { + let logs: Map + + beforeEach(() => { + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + }) + + it('.request()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.request('http://www.foobar.com:3500/fixtures/example.json') + }) + + cy.shouldWithTimeout(() => { + const { consoleProps, renderProps } = findCrossOriginLogs('request', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('request') + + expect(consoleProps.Request).to.have.property('Request Body').that.equals(null) + expect(consoleProps.Request).to.have.property('Request Headers').that.is.a('object') + expect(consoleProps.Request).to.have.property('Request URL').that.equals('http://www.foobar.com:3500/fixtures/example.json') + expect(consoleProps.Request).to.have.property('Response Body').that.is.a('string') + expect(consoleProps.Request).to.have.property('Response Headers').that.is.a('object') + expect(consoleProps.Request).to.have.property('Response Status').that.equals(200) + + expect(consoleProps.Yielded).to.have.property('body').that.deep.equals({ example: true }) + expect(consoleProps.Yielded).to.have.property('duration').that.is.a('number') + expect(consoleProps.Yielded).to.have.property('headers').that.is.a('object') + expect(consoleProps.Yielded).to.have.property('status').that.equals(200) + + expect(renderProps).to.have.property('indicator').that.equals('successful') + expect(renderProps).to.have.property('message').that.equals('GET 200 http://www.foobar.com:3500/fixtures/example.json') + }) + }) + }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_querying.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_querying.spec.ts index e22baad757ec..75fc0859bdb6 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_querying.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_querying.spec.ts @@ -1,3 +1,5 @@ +import { findCrossOriginLogs } from '../../../../support/utils' + context('cy.origin querying', () => { beforeEach(() => { cy.visit('/fixtures/multi-domain.html') @@ -29,4 +31,74 @@ context('cy.origin querying', () => { cy.root().should('match', 'html') }) }) + + context('#consoleProps', () => { + let logs: Map + + beforeEach(() => { + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + }) + + it('.contains()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.contains('Nested Find') + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('contains', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('contains') + expect(consoleProps['Applied To']).to.be.undefined + expect(consoleProps.Elements).to.equal(1) + expect(consoleProps.Content).to.equal('Nested Find') + expect(consoleProps.Yielded).to.have.property('tagName').that.equals('DIV') + expect(consoleProps.Yielded).to.have.property('id').that.equals('nested-find') + }) + }) + + it('.within()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#by-id').within(() => { + cy.get('#input') + }) + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('within', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('within') + expect(consoleProps.Yielded).to.have.property('tagName').that.equals('FORM') + expect(consoleProps.Yielded).to.have.property('id').that.equals('by-id') + }) + }) + + it('.root()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.root() + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('root', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('root') + expect(consoleProps.Yielded).to.have.property('tagName').that.equals('HTML') + }) + }) + }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_querying_shadow.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_querying_shadow.spec.ts index a7ec229ebad2..3171d39ef553 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_querying_shadow.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_querying_shadow.spec.ts @@ -1,3 +1,5 @@ +import { findCrossOriginLogs } from '../../../../support/utils' + context('cy.origin shadow dom', () => { beforeEach(() => { cy.visit('/fixtures/multi-domain.html') @@ -10,4 +12,40 @@ context('cy.origin shadow dom', () => { .should('have.text', 'Shadow Content 1') }) }) + + context('#consoleProps', () => { + let logs: Map + + beforeEach(() => { + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + }) + + it('.shadow()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#shadow-element-1').shadow() + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('shadow', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('shadow') + expect(consoleProps.Elements).to.equal(1) + + expect(consoleProps['Applied To']).to.have.property('tagName').that.equals('CY-TEST-ELEMENT') + expect(consoleProps['Applied To']).to.have.property('id').that.equals('shadow-element-1') + + expect(consoleProps.Yielded).to.be.null + }) + }) + }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_screenshot.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_screenshot.spec.ts index ecbeffff9919..dab3e9136dca 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_screenshot.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_screenshot.spec.ts @@ -275,4 +275,47 @@ context('cy.origin screenshot', () => { }) }) }) + + context('#consoleProps', () => { + const { findCrossOriginLogs } = require('../../../../support/utils') + let logs: Map + + beforeEach(() => { + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + + cy.visit('/fixtures/multi-domain.html') + cy.get('a[data-cy="screenshots-link"]').click() + }) + + it('.screenshot()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.screenshot({ capture: 'fullPage' }) + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('screenshot', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('screenshot') + + expect(consoleProps).to.have.property('blackout') + expect(consoleProps).to.have.property('capture').that.equals('fullPage') + expect(consoleProps).to.have.property('dimensions').that.is.a('string') + expect(consoleProps).to.have.property('disableTimersAndAnimations').that.is.a('boolean') + expect(consoleProps).to.have.property('duration').that.is.a('string') + expect(consoleProps).to.have.property('multipart').that.is.a('boolean') + expect(consoleProps).to.have.property('name').to.be.null + expect(consoleProps).to.have.property('path').that.is.a('string') + expect(consoleProps).to.have.property('pixelRatio').that.is.a('number') + expect(consoleProps).to.have.property('scaled').that.is.a('boolean') + expect(consoleProps).to.have.property('size').that.is.a('string') + expect(consoleProps).to.have.property('specName').that.is.a('string') + expect(consoleProps).to.have.property('takenAt').that.is.a('string') + expect(consoleProps).to.have.property('testAttemptIndex').that.is.a('number') + }) + }) + }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_spies_stubs_clocks.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_spies_stubs_clocks.spec.ts index 34bfa8b9bf55..51929443d1b8 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_spies_stubs_clocks.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_spies_stubs_clocks.spec.ts @@ -1,3 +1,5 @@ +import { findCrossOriginLogs } from '../../../../support/utils' + context('cy.origin spies, stubs, and clock', () => { beforeEach(() => { cy.visit('/fixtures/multi-domain.html') @@ -80,4 +82,101 @@ context('cy.origin spies, stubs, and clock', () => { }) }) }) + + context('#consoleProps', () => { + const { _ } = Cypress + let logs: Map + + beforeEach(() => { + logs = new Map() + + // cy.clock only adds a log and does NOT update + cy.on('log:added', (attrs, log) => { + logs.set(attrs.id, log) + }) + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + }) + + it('spy()', () => { + cy.origin('http://foobar.com:3500', () => { + const foo = { bar () { } } + + cy.spy(foo, 'bar') + foo.bar() + expect(foo.bar).to.be.called + }) + + cy.shouldWithTimeout(() => { + const spyLog = findCrossOriginLogs('spy-1', logs, 'foobar.com') + + expect(spyLog.consoleProps.Command).to.equal('spy-1') + expect(spyLog.callCount).to.be.a('number') + expect(spyLog.functionName).to.equal('bar') + }) + }) + + it('.stub()', () => { + cy.origin('http://foobar.com:3500', () => { + const foo = { bar () { } } + + cy.stub(foo, 'bar') + foo.bar() + expect(foo.bar).to.be.called + }) + + cy.shouldWithTimeout(() => { + const stubLog = findCrossOriginLogs('stub-1', logs, 'foobar.com') + + expect(stubLog.consoleProps.Command).to.equal('stub-1') + expect(stubLog.callCount).to.be.a('number') + expect(stubLog.functionName).to.equal('bar') + }) + }) + + it('.clock()', () => { + cy.origin('http://foobar.com:3500', () => { + const now = Date.UTC(2022, 0, 12) + + cy.clock(now) + }) + + cy.shouldWithTimeout(() => { + const clockLog = findCrossOriginLogs('clock', logs, 'foobar.com') + + expect(clockLog.name).to.equal('clock') + + const consoleProps = clockLog.consoleProps() + + expect(consoleProps.Command).to.equal('clock') + expect(consoleProps).to.have.property('Methods replaced').that.is.a('object') + expect(consoleProps).to.have.property('Now').that.is.a('number') + }) + }) + + it('.tick()', () => { + cy.origin('http://foobar.com:3500', () => { + const now = Date.UTC(2022, 0, 12) + + cy.clock(now) + + cy.tick(10000) + }) + + cy.shouldWithTimeout(() => { + const tickLog = findCrossOriginLogs('tick', logs, 'foobar.com') + + expect(tickLog.name).to.equal('tick') + + const consoleProps = _.isFunction(tickLog.consoleProps) ? tickLog.consoleProps() : tickLog.consoleProps + + expect(consoleProps.Command).to.equal('tick') + expect(consoleProps).to.have.property('Methods replaced').that.is.a('object') + expect(consoleProps).to.have.property('Now').that.is.a('number') + expect(consoleProps).to.have.property('Ticked').that.is.a('string') + }) + }) + }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_traversal.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_traversal.spec.ts index 9e06a6acbd6c..a06101342991 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_traversal.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_traversal.spec.ts @@ -1,3 +1,5 @@ +import { findCrossOriginLogs } from '../../../../support/utils' + context('cy.origin traversal', () => { beforeEach(() => { cy.visit('/fixtures/multi-domain.html') @@ -112,4 +114,531 @@ context('cy.origin traversal', () => { cy.get('#input').siblings().should('have.length', 2) }) }) + + context('#consoleProps', () => { + const { _ } = Cypress + let logs: Map + + beforeEach(() => { + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + }) + + it('.children()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#by-id').children() + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('children', logs, 'foobar.com') + + expect(consoleProps['Applied To']).to.have.property('tagName').that.equals('FORM') + expect(consoleProps['Applied To']).to.have.property('id').that.equals('by-id') + expect(consoleProps.Command).to.equal('children') + expect(consoleProps.Elements).to.equal(3) + expect(consoleProps.Selector).to.equal('') + expect(consoleProps.Yielded.length).to.equal(3) + expect(consoleProps.Yielded[0]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded[0]).to.have.property('id').that.equals('input') + expect(consoleProps.Yielded[1]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded[1]).to.have.property('id').that.equals('name') + expect(consoleProps.Yielded[2]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded[2]).to.have.property('id').that.equals('age') + }) + }) + + it('.closest()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#by-id').closest('form') + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('closest', logs, 'foobar.com') + + expect(consoleProps['Applied To']).to.have.property('tagName').that.equals('FORM') + expect(consoleProps['Applied To']).to.have.property('id').that.equals('by-id') + expect(consoleProps.Command).to.equal('closest') + expect(consoleProps.Elements).to.equal(1) + expect(consoleProps.Selector).to.equal('form') + expect(consoleProps.Yielded).to.have.property('tagName').that.equals('FORM') + expect(consoleProps.Yielded).to.have.property('id').that.equals('by-id') + expect(consoleProps.Yielded.querySelector('input#input')).to.be.ok + expect(consoleProps.Yielded.querySelector('input#name')).to.be.ok + expect(consoleProps.Yielded.querySelector('input#age')).to.be.ok + }) + }) + + it('.eq()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#by-id>input').eq(1) + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('eq', logs, 'foobar.com') + + expect(consoleProps['Applied To'].length).to.equal(3) + expect(consoleProps['Applied To'][0]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To'][0]).to.have.property('id').that.equals('input') + expect(consoleProps['Applied To'][1]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To'][1]).to.have.property('id').that.equals('name') + expect(consoleProps['Applied To'][2]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To'][2]).to.have.property('id').that.equals('age') + + expect(consoleProps.Command).to.equal('eq') + expect(consoleProps.Elements).to.equal(1) + expect(consoleProps.Selector).to.equal('1') + expect(consoleProps.Yielded).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded).to.have.property('id').that.equals('name') + }) + }) + + it('.filter()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#by-name>input') + .filter('[name="dogs"]') + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('filter', logs, 'foobar.com') + + expect(consoleProps['Applied To'].length).to.equal(12) + expect(consoleProps.Command).to.equal('filter') + expect(consoleProps.Elements).to.equal(4) + expect(consoleProps.Selector).to.equal('[name="dogs"]') + + expect(consoleProps.Yielded.length).to.equal(4) + + _.forEach(consoleProps.Yielded, (yielded) => { + expect(yielded).to.have.property('tagName').that.equals('INPUT') + expect(yielded).to.have.property('name').that.equals('dogs') + }) + }) + }) + + it('.find()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#by-id').find('input') + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('find', logs, 'foobar.com') + + expect(consoleProps['Applied To']).to.have.property('tagName').that.equals('FORM') + expect(consoleProps['Applied To']).to.have.property('id').that.equals('by-id') + + expect(consoleProps.Command).to.equal('find') + expect(consoleProps.Elements).to.equal(3) + expect(consoleProps.Selector).to.equal('input') + + expect(consoleProps.Yielded.length).to.equal(3) + expect(consoleProps.Yielded[0]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded[0]).to.have.property('id').that.equals('input') + expect(consoleProps.Yielded[1]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded[1]).to.have.property('id').that.equals('name') + expect(consoleProps.Yielded[2]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded[2]).to.have.property('id').that.equals('age') + }) + }) + + it('.first()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#by-id>input').first() + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('first', logs, 'foobar.com') + + expect(consoleProps['Applied To'].length).to.equal(3) + expect(consoleProps['Applied To'][0]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To'][0]).to.have.property('id').that.equals('input') + expect(consoleProps['Applied To'][1]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To'][1]).to.have.property('id').that.equals('name') + expect(consoleProps['Applied To'][2]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To'][2]).to.have.property('id').that.equals('age') + + expect(consoleProps.Command).to.equal('first') + expect(consoleProps.Elements).to.equal(1) + expect(consoleProps.Selector).to.equal('') + + expect(consoleProps.Yielded).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded).to.have.property('id').that.equals('input') + }) + }) + + it('.last()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#by-id>input').last() + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('last', logs, 'foobar.com') + + expect(consoleProps['Applied To'].length).to.equal(3) + expect(consoleProps['Applied To'][0]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To'][0]).to.have.property('id').that.equals('input') + expect(consoleProps['Applied To'][1]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To'][1]).to.have.property('id').that.equals('name') + expect(consoleProps['Applied To'][2]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To'][2]).to.have.property('id').that.equals('age') + + expect(consoleProps.Command).to.equal('last') + expect(consoleProps.Elements).to.equal(1) + expect(consoleProps.Selector).to.equal('') + + expect(consoleProps.Yielded).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded).to.have.property('id').that.equals('age') + }) + }) + + it('.next()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#input').next() + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('next', logs, 'foobar.com') + + expect(consoleProps['Applied To']).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To']).to.have.property('id').that.equals('input') + + expect(consoleProps.Command).to.equal('next') + expect(consoleProps.Elements).to.equal(1) + expect(consoleProps.Selector).to.equal('') + + expect(consoleProps.Yielded).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded).to.have.property('id').that.equals('name') + }) + }) + + it('.nextAll()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#input').nextAll() + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('nextAll', logs, 'foobar.com') + + expect(consoleProps['Applied To']).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To']).to.have.property('id').that.equals('input') + + expect(consoleProps.Command).to.equal('nextAll') + expect(consoleProps.Elements).to.equal(2) + expect(consoleProps.Selector).to.equal('') + + expect(consoleProps.Yielded.length).to.equal(2) + expect(consoleProps.Yielded[0]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded[0]).to.have.property('id').that.equals('name') + expect(consoleProps.Yielded[1]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded[1]).to.have.property('id').that.equals('age') + }) + }) + + it('.nextUntil()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#input').nextUntil('#age') + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('nextUntil', logs, 'foobar.com') + + expect(consoleProps['Applied To']).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To']).to.have.property('id').that.equals('input') + + expect(consoleProps.Command).to.equal('nextUntil') + expect(consoleProps.Elements).to.equal(1) + expect(consoleProps.Selector).to.equal('#age') + + expect(consoleProps.Yielded).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded).to.have.property('id').that.equals('name') + }) + }) + + it('.not()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#by-id>input').not('#age') + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('not', logs, 'foobar.com') + + expect(consoleProps['Applied To'].length).to.equal(3) + expect(consoleProps['Applied To'][0]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To'][0]).to.have.property('id').that.equals('input') + expect(consoleProps['Applied To'][1]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To'][1]).to.have.property('id').that.equals('name') + expect(consoleProps['Applied To'][2]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To'][2]).to.have.property('id').that.equals('age') + + expect(consoleProps.Command).to.equal('not') + expect(consoleProps.Elements).to.equal(2) + expect(consoleProps.Selector).to.equal('#age') + + expect(consoleProps.Yielded.length).to.equal(2) + expect(consoleProps.Yielded[0]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded[0]).to.have.property('id').that.equals('input') + expect(consoleProps.Yielded[1]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded[1]).to.have.property('id').that.equals('name') + }) + }) + + it('.parent()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#by-id').parent() + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('parent', logs, 'foobar.com') + + expect(consoleProps['Applied To']).to.have.property('tagName').that.equals('FORM') + expect(consoleProps['Applied To']).to.have.property('id').that.equals('by-id') + + expect(consoleProps.Command).to.equal('parent') + expect(consoleProps.Elements).to.equal(1) + expect(consoleProps.Selector).to.equal('') + + expect(consoleProps.Yielded).to.have.property('tagName').that.equals('DIV') + expect(consoleProps.Yielded).to.have.property('id').that.equals('dom') + }) + }) + + it('.parents()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#by-id').parents() + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('parents', logs, 'foobar.com') + + expect(consoleProps['Applied To']).to.have.property('tagName').that.equals('FORM') + expect(consoleProps['Applied To']).to.have.property('id').that.equals('by-id') + + expect(consoleProps.Command).to.equal('parents') + expect(consoleProps.Elements).to.equal(3) + expect(consoleProps.Selector).to.equal('') + + expect(consoleProps.Yielded.length).to.equal(3) + expect(consoleProps.Yielded[0]).to.have.property('tagName').that.equals('DIV') + expect(consoleProps.Yielded[0]).to.have.property('id').that.equals('dom') + expect(consoleProps.Yielded[1]).to.have.property('tagName').that.equals('BODY') + expect(consoleProps.Yielded[2]).to.have.property('tagName').that.equals('HTML') + }) + }) + + it('.parentsUntil()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#by-id').parentsUntil('body') + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('parentsUntil', logs, 'foobar.com') + + expect(consoleProps['Applied To']).to.have.property('tagName').that.equals('FORM') + expect(consoleProps['Applied To']).to.have.property('id').that.equals('by-id') + + expect(consoleProps.Command).to.equal('parentsUntil') + expect(consoleProps.Elements).to.equal(1) + expect(consoleProps.Selector).to.equal('body') + + expect(consoleProps.Yielded).to.have.property('tagName').that.equals('DIV') + expect(consoleProps.Yielded).to.have.property('id').that.equals('dom') + }) + }) + + it('.prev()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#age').prev() + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('prev', logs, 'foobar.com') + + expect(consoleProps['Applied To']).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To']).to.have.property('id').that.equals('age') + + expect(consoleProps.Command).to.equal('prev') + expect(consoleProps.Elements).to.equal(1) + expect(consoleProps.Selector).to.equal('') + + expect(consoleProps.Yielded).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded).to.have.property('id').that.equals('name') + }) + }) + + it('.prevAll()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#age').prevAll() + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('prevAll', logs, 'foobar.com') + + expect(consoleProps['Applied To']).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To']).to.have.property('id').that.equals('age') + + expect(consoleProps.Command).to.equal('prevAll') + expect(consoleProps.Elements).to.equal(2) + expect(consoleProps.Selector).to.equal('') + + expect(consoleProps.Yielded.length).to.equal(2) + expect(consoleProps.Yielded[0]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded[0]).to.have.property('id').that.equals('name') + expect(consoleProps.Yielded[1]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded[1]).to.have.property('id').that.equals('input') + }) + }) + + it('.prevUntil()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#age').prevUntil('#input') + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('prevUntil', logs, 'foobar.com') + + expect(consoleProps['Applied To']).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To']).to.have.property('id').that.equals('age') + + expect(consoleProps.Command).to.equal('prevUntil') + expect(consoleProps.Elements).to.equal(1) + expect(consoleProps.Selector).to.equal('#input') + + expect(consoleProps.Yielded).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded).to.have.property('id').that.equals('name') + }) + }) + + it('.siblings()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.get('#input').siblings() + }) + + cy.shouldWithTimeout(() => { + // in the case of some firefox browsers, the document state is left in a cross origin context when running these assertions + // set to context to undefined to run the assertions + if (Cypress.isBrowser('firefox')) { + cy.state('document', undefined) + } + + const { consoleProps } = findCrossOriginLogs('siblings', logs, 'foobar.com') + + expect(consoleProps['Applied To']).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps['Applied To']).to.have.property('id').that.equals('input') + + expect(consoleProps.Command).to.equal('siblings') + expect(consoleProps.Elements).to.equal(2) + expect(consoleProps.Selector).to.equal('') + + expect(consoleProps.Yielded.length).to.equal(2) + expect(consoleProps.Yielded[0]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded[0]).to.have.property('id').that.equals('name') + expect(consoleProps.Yielded[1]).to.have.property('tagName').that.equals('INPUT') + expect(consoleProps.Yielded[1]).to.have.property('id').that.equals('age') + }) + }) + }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_viewport.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_viewport.spec.ts index e49a346b961a..cfe34498fda2 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_viewport.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_viewport.spec.ts @@ -1,3 +1,5 @@ +import { findCrossOriginLogs } from '../../../../support/utils' + context('cy.origin viewport', () => { it('syncs the viewport from the primary to secondary', () => { // change the viewport in the primary first @@ -178,5 +180,31 @@ context('cy.origin viewport', () => { }) }) }) + + context('#consoleProps', () => { + let logs: Map + + beforeEach(() => { + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + }) + + it('.viewport()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.viewport(320, 480) + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('viewport', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('viewport') + expect(consoleProps.Width).to.equal(320) + expect(consoleProps.Height).to.equal(480) + }) + }) + }) }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_waiting.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_waiting.spec.ts index 54a7f51bcb7c..67f188e603e7 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_waiting.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_waiting.spec.ts @@ -1,3 +1,5 @@ +import { findCrossOriginLogs } from '../../../../support/utils' + context('cy.origin waiting', () => { beforeEach(() => { cy.visit('/fixtures/multi-domain.html') @@ -9,4 +11,29 @@ context('cy.origin waiting', () => { cy.wait(500) }) }) + + context('#consoleProps', () => { + let logs: Map + + beforeEach(() => { + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + }) + + it('.wait()', () => { + cy.origin('http://foobar.com:3500', () => { + cy.wait(200) + }) + + cy.shouldWithTimeout(() => { + const { consoleProps } = findCrossOriginLogs('wait', logs, 'foobar.com') + + expect(consoleProps.Command).to.equal('wait') + expect(consoleProps).to.have.property('Waited For').to.equal('200ms before continuing') + }) + }) + }) }) diff --git a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_window.spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_window.spec.ts index 95b56f279d36..84d7bfc03708 100644 --- a/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_window.spec.ts +++ b/packages/driver/cypress/integration/e2e/multi-domain/commands/multi_domain_window.spec.ts @@ -1,3 +1,5 @@ +import { findCrossOriginLogs } from '../../../../support/utils' + context('cy.origin window', () => { beforeEach(() => { cy.visit('/fixtures/multi-domain.html') @@ -21,4 +23,55 @@ context('cy.origin window', () => { cy.title().should('include', 'DOM Fixture') }) }) + + context('#consoleProps', () => { + let logs: Map + + 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) => (