Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: support snapshots of xhr/fetch and other logs generated from the primary #21552

Merged
merged 12 commits into from
Aug 17, 2022
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/app/src/runner/event-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,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,
})
Comment on lines +626 to +635
Copy link
Contributor

Choose a reason for hiding this comment

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

This might be one of those cases where adding a promise-based API to the communicator would be useful. Then it could be something like:

const generatedCrossOriginSnapshot = await 
Cypress.primaryOriginCommunicator.toSpecBridgeAsync(specBridge, 'generate:snapshot:for:log')

const snapshot = generatedCrossOriginSnapshot.body ? generatedCrossOriginSnapshot : null

addSnapshot.apply(log, [snapshot, options, false])

I'm not suggesting adding it in this PR, but wanted to call it out as an example. Maybe when we have another use-case, we could add that and refactor this one.

})

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
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/runner/iframe-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
99 changes: 99 additions & 0 deletions packages/driver/cypress/e2e/e2e/origin/snapshots.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// import to bind shouldWithTimeout into global cy commands
import '../../../support/utils'

describe('cy.origin - snapshots', () => {
const findLog = (logMap: Map<string, any>, 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<string, any>

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')
})
})
})
1 change: 1 addition & 0 deletions packages/driver/cypress/fixtures/primary-origin.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<li><a data-cy="files-form-link" href="http://www.foobar.com:3500/fixtures/files-form.html">http://www.foobar.com:3500/fixtures/files-form.html</a></li>
<li><a data-cy="errors-link" href="http://www.foobar.com:3500/fixtures/errors.html">http://www.foobar.com:3500/fixtures/errors.html</a></li>
<li><a data-cy="screenshots-link" href="http://www.foobar.com:3500/fixtures/screenshots.html">http://www.foobar.com:3500/fixtures/screenshots.html</a></li>
<li><a data-cy="xhr-fetch-requests" href="http://www.foobar.com:3500/fixtures/xhr-fetch-onload.html">http://www.foobar.com:3500/fixtures/xhr-fetch-onload.html</a></li>
<li><a data-cy="integrity-link" href="http://www.foobar.com:3500/fixtures/scripts-with-integrity.html">http://www.foobar.com:3500/fixtures/scripts-with-integrity.html</a></li>
<li><a data-cy="cookie-login">Login with Social</a></li>
<li><a data-cy="cookie-login-https">Login with Social (https)</a></li>
Expand Down
18 changes: 18 additions & 0 deletions packages/driver/cypress/fixtures/xhr-fetch-onload.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<body>
<h1 data-cy="assertion-header">Making XHR and Fetch Requests behind the scenes!</h1>
<script>
function fireXHRAndFetchRequests() {

xhr = new XMLHttpRequest();
xhr.open("GET", "http://localhost:3500/foo.bar.baz.json");
xhr.responseType = "json";
xhr.send();

fetch("http://localhost:3500/foo.bar.baz.json")
}
fireXHRAndFetchRequests()
</script>
</body>
</html>
11 changes: 6 additions & 5 deletions packages/driver/src/cross-origin/communicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
mjhenkes marked this conversation as resolved.
Show resolved Hide resolved
data.data = reifySnapshotFromSerialization(data.data as any)
}

Expand Down Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

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

nit: lets specify @packages/runner since the driver has a Runner class as well

Copy link
Member

Choose a reason for hiding this comment

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

// 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)) {
mjhenkes marked this conversation as resolved.
Show resolved Hide resolved
data = preprocessSnapshotForSerialization(data as any)
}

Expand Down
15 changes: 14 additions & 1 deletion packages/driver/src/cross-origin/cypress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}

Expand Down
64 changes: 44 additions & 20 deletions packages/driver/src/cypress/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,47 +341,71 @@ 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
// if the snapshotting process errors
// https://github.com/cypress-io/cypress/issues/15816
if (snapshot) {
// insert at index 'at' or whatever is the next position
// insert at index 'at' or whatever is the next position
AtofStryker marked this conversation as resolved.
Show resolved Hide resolved
snapshots[options.at || snapshots.length] = snapshot
}

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
AtofStryker marked this conversation as resolved.
Show resolved Hide resolved
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) {
Copy link
Member

Choose a reason for hiding this comment

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

Would it be possible to somehow tie this to the command group the log is part of? Like add the originPolicy to the command group and use it for any events generated within that group? @emilyrohrbough, is something like that possible (or better)?

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) {
Copy link
Member

Choose a reason for hiding this comment

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

In what scenarios is this not true? If we are in a cy.origin block but have begun navigating to another origin? Does it make a difference if it's the top origin or another cross origin?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Mainly the error case/operator error. If a user navigates to an origin that doesn't match the spec bridge origin, like

cy.get('[href="https://www.foobar.com"]').click()
cy.origin('https://www.barbaz.com', () => {
   // wrong origin, don't snapshot this
})

I added a test here to make sure this fails gracefully.

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') || []

Expand Down