diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index 4f7238068b8e..f8170a65df74 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -614,6 +614,27 @@ export class EventManager { Cypress.primaryOriginCommunicator.toAllSpecBridges('before:unload') }) + Cypress.on('request:snapshot:from:spec:bridge', ({ log, name, options, specBridge, addSnapshot }: { + log: Cypress.Log + name?: string + options?: any + specBridge: string + addSnapshot: (snapshot: any, options: any, shouldRebindSnapshotFn: boolean) => Cypress.Log + }) => { + const eventID = log.get('id') + + Cypress.primaryOriginCommunicator.once(`snapshot:for:log:generated:${eventID}`, (generatedCrossOriginSnapshot) => { + const snapshot = generatedCrossOriginSnapshot.body ? generatedCrossOriginSnapshot : null + + addSnapshot.apply(log, [snapshot, options, false]) + }) + + Cypress.primaryOriginCommunicator.toSpecBridge(specBridge, 'generate:snapshot:for:log', { + name, + id: eventID, + }) + }) + Cypress.primaryOriginCommunicator.on('window:load', ({ url }, originPolicy) => { // Sync stable if the expected origin has loaded. // Only listen to window load events from the most recent secondary origin, This prevents nondeterminism in the case where we redirect to an already diff --git a/packages/app/src/runner/iframe-model.ts b/packages/app/src/runner/iframe-model.ts index 9ce18a998a51..a8cdbfabf41c 100644 --- a/packages/app/src/runner/iframe-model.ts +++ b/packages/app/src/runner/iframe-model.ts @@ -258,7 +258,7 @@ export class IframeModel { * 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', autStore.url || '') - Cypress.primaryOriginCommunicator.once('final:snapshot:generated', (finalSnapshot) => { + Cypress.primaryOriginCommunicator.once('snapshot:final:generated', (finalSnapshot) => { // todo(lachlan): UNIFY-1318 - find correct default, if they are even needed, for required fields ($el, coords...) // @ts-ignore this.originalState = { diff --git a/packages/driver/cypress/e2e/e2e/origin/snapshots.cy.ts b/packages/driver/cypress/e2e/e2e/origin/snapshots.cy.ts new file mode 100644 index 000000000000..08af38a0a00e --- /dev/null +++ b/packages/driver/cypress/e2e/e2e/origin/snapshots.cy.ts @@ -0,0 +1,99 @@ +// import to bind shouldWithTimeout into global cy commands +import '../../../support/utils' + +describe('cy.origin - snapshots', () => { + const findLog = (logMap: Map, displayName: string, url: string) => { + return Array.from(logMap.values()).find((log: any) => { + const props = log.get() + + return props.displayName === displayName && (props?.consoleProps?.URL === url || props?.consoleProps()?.URL === url) + }) + } + let logs: Map + + beforeEach(() => { + logs = new Map() + + cy.on('log:changed', (attrs, log) => { + logs.set(attrs.id, log) + }) + + cy.fixture('foo.bar.baz.json').then((fooBarBaz) => { + cy.intercept('GET', '/foo.bar.baz.json', { body: fooBarBaz }).as('fooBarBaz') + }) + + cy.visit('/fixtures/primary-origin.html') + cy.get('a[data-cy="xhr-fetch-requests"]').click() + }) + + it('verifies XHR requests made while a secondary origin is active eventually update with snapshots of the secondary origin', () => { + cy.origin('http://foobar.com:3500', () => { + // need to set isInteractive in the spec bridge in order to take xhr snapshots in run mode, similar to how isInteractive is set within support/defaults.js + // @ts-ignore + Cypress.config('isInteractive', true) + cy.get(`[data-cy="assertion-header"]`).should('exist') + cy.wait('@fooBarBaz') + }) + + cy.shouldWithTimeout(() => { + const xhrLogFromSecondaryOrigin = findLog(logs, 'xhr', 'http://localhost:3500/foo.bar.baz.json')?.get() + + expect(xhrLogFromSecondaryOrigin).to.not.be.undefined + + const snapshots = xhrLogFromSecondaryOrigin.snapshots.map((snapshot) => snapshot.body.get()[0]) + + snapshots.forEach((snapshot) => { + expect(snapshot.querySelector(`[data-cy="assertion-header"]`)).to.have.property('innerText').that.equals('Making XHR and Fetch Requests behind the scenes!') + }) + }) + }) + + it('verifies fetch requests made while a secondary origin is active eventually update with snapshots of the secondary origin', () => { + cy.origin('http://foobar.com:3500', () => { + // need to set isInteractive in the spec bridge in order to take xhr snapshots in run mode, similar to how isInteractive is set within support/defaults.js + // @ts-ignore + Cypress.config('isInteractive', true) + cy.get(`[data-cy="assertion-header"]`).should('exist') + cy.wait('@fooBarBaz') + }) + + cy.shouldWithTimeout(() => { + const xhrLogFromSecondaryOrigin = findLog(logs, 'fetch', 'http://localhost:3500/foo.bar.baz.json')?.get() + + expect(xhrLogFromSecondaryOrigin).to.not.be.undefined + + const snapshots = xhrLogFromSecondaryOrigin.snapshots.map((snapshot) => snapshot.body.get()[0]) + + snapshots.forEach((snapshot) => { + expect(snapshot.querySelector(`[data-cy="assertion-header"]`)).to.have.property('innerText').that.equals('Making XHR and Fetch Requests behind the scenes!') + }) + }) + }) + + it('Does not take snapshots of XHR/fetch requests from secondary origin if the wrong origin is / origin mismatch, but instead the primary origin (existing behavior)', { + pageLoadTimeout: 5000, + }, + (done) => { + cy.on('fail', () => { + const xhrLogFromSecondaryOrigin = findLog(logs, 'fetch', 'http://localhost:3500/foo.bar.baz.json')?.get() + + expect(xhrLogFromSecondaryOrigin).to.not.be.undefined + + const snapshots = xhrLogFromSecondaryOrigin.snapshots.map((snapshot) => snapshot.body.get()[0]) + + snapshots.forEach((snapshot) => { + expect(snapshot.querySelector(`[data-cy="assertion-header"]`)).to.be.null + }) + + done() + }) + + cy.origin('http://barbaz.com:3500', () => { + // need to set isInteractive in the spec bridge in order to take xhr snapshots in run mode, similar to how isInteractive is set within support/defaults.js + // @ts-ignore + Cypress.config('isInteractive', true) + cy.get(`[data-cy="assertion-header"]`).should('exist') + cy.wait('@fooBarBaz') + }) + }) +}) diff --git a/packages/driver/cypress/fixtures/primary-origin.html b/packages/driver/cypress/fixtures/primary-origin.html index d1c49ec73d7d..916d112b18ba 100644 --- a/packages/driver/cypress/fixtures/primary-origin.html +++ b/packages/driver/cypress/fixtures/primary-origin.html @@ -13,6 +13,7 @@
  • http://www.foobar.com:3500/fixtures/files-form.html
  • http://www.foobar.com:3500/fixtures/errors.html
  • http://www.foobar.com:3500/fixtures/screenshots.html
  • +
  • http://www.foobar.com:3500/fixtures/xhr-fetch-onload.html
  • http://www.foobar.com:3500/fixtures/scripts-with-integrity.html
  • Login with Social
  • Login with Social (https)
  • diff --git a/packages/driver/cypress/fixtures/xhr-fetch-onload.html b/packages/driver/cypress/fixtures/xhr-fetch-onload.html new file mode 100644 index 000000000000..65fa87ea0cc4 --- /dev/null +++ b/packages/driver/cypress/fixtures/xhr-fetch-onload.html @@ -0,0 +1,18 @@ + + + +

    Making XHR and Fetch Requests behind the scenes!

    + + + \ No newline at end of file diff --git a/packages/driver/src/cross-origin/communicator.ts b/packages/driver/src/cross-origin/communicator.ts index 0e98df5ee5ef..477407a7f543 100644 --- a/packages/driver/src/cross-origin/communicator.ts +++ b/packages/driver/src/cross-origin/communicator.ts @@ -9,7 +9,7 @@ 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` +const SNAPSHOT_EVENT_PREFIX = `${CROSS_ORIGIN_PREFIX}snapshot:` /** * Primary Origin communicator. Responsible for sending/receiving events throughout @@ -50,8 +50,8 @@ export class PrimaryOriginCommunicator extends EventEmitter { 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) { + // reify the final or requested snapshot coming back from the secondary domain if requested by the runner. + if (data?.event.includes(SNAPSHOT_EVENT_PREFIX) && !Cypress._.isEmpty(data?.data)) { data.data = reifySnapshotFromSerialization(data.data as any) } @@ -191,8 +191,9 @@ export class SpecBridgeCommunicator extends EventEmitter { 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) { + // If requested by the runner, preprocess the snapshot before sending through postMessage() to attempt to serialize the DOM body of the snapshot. + // NOTE: SNAPSHOT_EVENT_PREFIX events, if requested by the log manager, are namespaced per primary log + if (eventName.includes(SNAPSHOT_EVENT_PREFIX) && !Cypress._.isEmpty(data)) { data = preprocessSnapshotForSerialization(data as any) } diff --git a/packages/driver/src/cross-origin/cypress.ts b/packages/driver/src/cross-origin/cypress.ts index c30c4485f861..2724fd9da847 100644 --- a/packages/driver/src/cross-origin/cypress.ts +++ b/packages/driver/src/cross-origin/cypress.ts @@ -43,10 +43,23 @@ const createCypress = () => { // 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('snapshot:final:generated', finalSnapshot) } }) + Cypress.specBridgeCommunicator.on('generate:snapshot:for:log', ({ name, id }) => { + // if the snapshot cannot be taken (in a transitory space), set to an empty object in order to not fail serialization + let requestedCrossOriginSnapshot = {} + + // don't attempt to take snapshots after the spec bridge has been unloaded. Instead, send an empty snapshot back to the primary + // to display current state of dom + if (cy.state('document') !== undefined) { + requestedCrossOriginSnapshot = cy.createSnapshot(name) || {} + } + + Cypress.specBridgeCommunicator.toPrimary(`snapshot:for:log:generated:${id}`, requestedCrossOriginSnapshot) + }) + Cypress.specBridgeCommunicator.toPrimary('bridge:ready') } diff --git a/packages/driver/src/cypress/log.ts b/packages/driver/src/cypress/log.ts index 7346dbf178fa..56ee9ed5fa73 100644 --- a/packages/driver/src/cypress/log.ts +++ b/packages/driver/src/cypress/log.ts @@ -341,20 +341,7 @@ export class Log { return _.pick(this.attributes, args) } - snapshot (name?, options: any = {}) { - // bail early and don't snapshot if we're in headless mode - // or we're not storing tests - if (!this.config('isInteractive') || (this.config('numTestsKeptInMemory') === 0)) { - return this - } - - _.defaults(options, { - at: null, - next: null, - }) - - const snapshot = this.cy.createSnapshot(name, this.get('$el')) - + private addSnapshot (snapshot, options, shouldRebindSnapshotFn = true) { const snapshots = this.get('snapshots') || [] // don't add snapshot if we couldn't create one, which can happen @@ -367,21 +354,58 @@ export class Log { this.set('snapshots', snapshots) - if (options.next) { - const fn = this.snapshot + if (options.next && shouldRebindSnapshotFn) { + const originalLogSnapshotFn = this.snapshot this.snapshot = function () { - // restore the fn - this.snapshot = fn + // restore the original snapshot function + this.snapshot = originalLogSnapshotFn // call orig fn with next as name - return fn.call(this, options.next) + return originalLogSnapshotFn.call(this, options.next) } } return this } + snapshot (name?, options: any = {}) { + // bail early and don't snapshot if we're in headless mode + // or we're not storing tests + if (!this.config('isInteractive') || (this.config('numTestsKeptInMemory') === 0)) { + return this + } + + _.defaults(options, { + at: null, + next: null, + }) + + if (this.config('experimentalSessionAndOrigin') && !Cypress.isCrossOriginSpecBridge) { + const activeSpecBridgeOriginPolicyIfApplicable = this.state('currentActiveOriginPolicy') || undefined + // @ts-ignore + const { originPolicy: originPolicyThatIsSoonToBeOrIsActive } = Cypress.Location.create(this.state('anticipatingCrossOriginResponse')?.href || this.state('url')) + + if (activeSpecBridgeOriginPolicyIfApplicable && activeSpecBridgeOriginPolicyIfApplicable === originPolicyThatIsSoonToBeOrIsActive) { + Cypress.emit('request:snapshot:from:spec:bridge', { + log: this, + name, + options, + specBridge: activeSpecBridgeOriginPolicyIfApplicable, + addSnapshot: this.addSnapshot, + }) + + return this + } + } + + const snapshot = this.cy.createSnapshot(name, this.get('$el')) + + this.addSnapshot(snapshot, options) + + return this + } + error (err) { const logGroupIds = this.state('logGroupIds') || []