Skip to content

Commit

Permalink
chore: refactor cross domain communications (#19430)
Browse files Browse the repository at this point in the history
Co-authored-by: Chris Breiding <chrisbreiding@gmail.com>
  • Loading branch information
AtofStryker and chrisbreiding authored Jan 6, 2022
1 parent 00dae70 commit 388bfa4
Show file tree
Hide file tree
Showing 11 changed files with 243 additions and 202 deletions.
5 changes: 5 additions & 0 deletions cli/types/cypress.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ declare namespace Cypress {
visiting: string
}

interface ReadyForDomainOptions {
shouldInject: boolean
}

interface Backend {
/**
* Firefox only: Force Cypress to run garbage collection routines.
Expand All @@ -68,6 +72,7 @@ declare namespace Cypress {
* @see https://on.cypress.io/firefox-gc-issue
*/
(task: 'firefox:force:gc'): Promise<void>
(task: 'ready:for:domain', options: ReadyForDomainOptions): Promise<void>
(task: 'net', eventName: string, frame: any): Promise<void>
}

Expand Down
6 changes: 3 additions & 3 deletions packages/driver/src/cy/commands/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,13 +345,13 @@ const stabilityChanged = (Cypress, state, config, stable) => {
debug('waiting for window:load')

return new Promise((resolve) => {
const onWindowLoad = (e) => {
const onWindowLoad = (win) => {
// this prevents a log occurring when we navigate to about:blank inbetween tests
if (!state('duringUserTestExecution')) return

cy.state('onPageLoadErr', null)

if (e.window.location.href === 'about:blank') {
if (win.location.href === 'about:blank') {
// we treat this as a system log since navigating to about:blank must have been caused by Cypress
options._log.set({ message: '', name: 'Clear Page', type: 'system' }).snapshot().end()
} else {
Expand All @@ -378,7 +378,7 @@ const stabilityChanged = (Cypress, state, config, stable) => {
const onInternalWindowLoad = (details) => {
switch (details.type) {
case 'same:domain':
return onWindowLoad(details.event)
return onWindowLoad(details.window)
case 'cross:domain':
return onCrossDomainWindowLoad()
case 'cross:domain:failure':
Expand Down
44 changes: 14 additions & 30 deletions packages/driver/src/cy/multidomain/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import Bluebird from 'bluebird'
import $Log from '../../cypress/log'
import { createDeferred } from '../../util/deferred'

export function addCommands (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, state: Cypress.State) {
let timeoutId

// @ts-ignore
Cypress.on('cross:domain:html:received', () => {
const communicator = Cypress.multiDomainCommunicator

communicator.on('html:received', () => {
// when a secondary domain is detected by the proxy, it holds it up
// to provide time for the spec bridge to be set up. normally, the queue
// will not continue until the page is stable, but this signals it to go
Expand All @@ -18,7 +19,6 @@ export function addCommands (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy,
// if the next command isn't switchToDomain, this timeout will hit and
// the test will fail with a cross-origin error
timeoutId = setTimeout(() => {
// @ts-ignore
Cypress.backend('ready:for:domain', { shouldInject: false })
}, 2000)
})
Expand Down Expand Up @@ -56,11 +56,10 @@ export function addCommands (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy,
// own timeout
// TODO: add a special, long timeout in case inter-domain
// communication breaks down somehow
// @ts-ignore
cy.clearTimeout()

Cypress.action('cy:cross:domain:message', {
message: 'run:command',
communicator.toSpecBridge('run:command', {
name: attrs.name,
})

return deferred.promise
Expand Down Expand Up @@ -103,47 +102,32 @@ export function addCommands (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy,
}
}

// @ts-ignore
Cypress.on('cross:domain:command:enqueued', addCommand)
// @ts-ignore
Cypress.on('cross:domain:command:update', updateCommand)
communicator.on('command:enqueued', addCommand)
communicator.on('command:update', updateCommand)

return new Bluebird((resolve) => {
// @ts-ignore
Cypress.once('cross:domain:ran:domain:fn', () => {
resolve()
})
communicator.once('ran:domain:fn', resolve)

// @ts-ignore
Cypress.once('cross:domain:queue:finished', () => {
// @ts-ignore
Cypress.off('cross:domain:command:enqueued', addCommand)
// @ts-ignore
Cypress.off('cross:domain:command:update', updateCommand)
communicator.once('queue:finished', () => {
communicator.off('command:enqueued', addCommand)
communicator.off('command:update', updateCommand)
})

// fired once the spec bridge is set up and ready to
// receive messages
// @ts-ignore
Cypress.once('cross:domain:bridge:ready', () => {
communicator.once('bridge:ready', () => {
state('readyForMultidomain', true)
// let the proxy know to let the response for the secondary
// domain html through, so the page will finish loading
// @ts-ignore
Cypress.backend('ready:for:domain', { shouldInject: true })
})

// @ts-ignore
cy.once('internal:window:load', ({ type }) => {
if (type !== 'cross:domain') return

// once the secondary domain page loads, send along the
// user-specified callback to run in that domain
Cypress.action('cy:cross:domain:message', {
message: 'run:domain:fn',
// the log count needs to be synced between domains so logs
// are guaranteed to have unique ids
logCounter: $Log.getCounter(),
communicator.toSpecBridge('run:domain:fn', {
fn: fn.toString(),
})

Expand All @@ -154,7 +138,7 @@ export function addCommands (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy,

// this signals to the runner to create the spec bridge for
// the specified domain
Cypress.action('cy:expect:domain', domain)
communicator.emit('expect:domain', domain)
})
},
})
Expand Down
37 changes: 3 additions & 34 deletions packages/driver/src/cypress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import ProxyLogging from './cypress/proxy-logging'
import * as $Events from './cypress/events'
import $Keyboard from './cy/keyboard'
import * as resolvers from './cypress/resolvers'
import { PrimaryDomainCommunicator } from './multidomain/communicator'

const debug = debugFn('cypress:driver:cypress')

Expand Down Expand Up @@ -66,6 +67,7 @@ class $Cypress {
this.Commands = null
this.$autIframe = null
this.onSpecReady = null
this.multiDomainCommunicator = new PrimaryDomainCommunicator()

this.events = $Events.extend(this)
this.$ = jqueryProxyFn.bind(this)
Expand Down Expand Up @@ -553,7 +555,7 @@ class $Cypress {
case 'app:window:load':
this.emit('internal:window:load', {
type: 'same:domain',
event: args[0],
window: args[0],
})

return this.emit('window:load', args[0])
Expand All @@ -576,39 +578,6 @@ class $Cypress {
case 'spec:script:error':
return this.emit('script:error', ...args)

// multidomain messages
// TODO: consider moving these elsewhere if they grow too
// large in number
case 'cy:expect:domain':
return this.emit('expect:domain', args[0])

case 'runner:cross:domain:bridge:ready':
return this.emit('cross:domain:bridge:ready')

case 'runner:cross:domain:window:load':
return this.emit('internal:window:load', { type: 'cross:domain' })

case 'cy:cross:domain:failure':
return this.emit('internal:window:load', {
type: 'cross:domain:failure',
error: args[0],
})

case 'runner:cross:domain:ran:domain:fn':
return this.emit('cross:domain:ran:domain:fn')

case 'runner:cross:domain:queue:finished':
return this.emit('cross:domain:queue:finished')

case 'runner:cross:domain:command:enqueued':
return this.emit('cross:domain:command:enqueued', ...args)

case 'runner:cross:domain:command:update':
return this.emit('cross:domain:command:update', ...args)

case 'cy:cross:domain:message':
return this.emit('cross:domain:message', ...args)

default:
return
}
Expand Down
14 changes: 11 additions & 3 deletions packages/driver/src/cypress/command_queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,17 @@ export class CommandQueue extends Queue<Command> {
let pause = false

const next = () => {
// start at 0 index if one is not already set
let index = this.state('index') || this.state('index', 0)

// if at the end of the queue in a secondary domain,
// ignore and reset the pause, then let the queue finish
if (!autoRun && pause && !this.at(index)) {
pause = false

return next()
}

// when running in a secondary domain (SD), the primary domain (PD)
// has proxy commands that represent the real commands run in the SD.
// for everything to sync up properly, the PD controls the running of
Expand All @@ -280,9 +291,6 @@ export class CommandQueue extends Queue<Command> {
return
}

// start at 0 index if we dont have one
let index = this.state('index') || this.state('index', 0)

const command = this.at(index)

// if the command should be skipped, just bail and increment index
Expand Down
5 changes: 4 additions & 1 deletion packages/driver/src/cypress/cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,10 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert
e = onpl(e)
}

Cypress.action('cy:cross:domain:failure', e)
this.Cypress.emit('internal:window:load', {
type: 'cross:domain:failure',
error: e,
})

// need async:true since this is outside the command queue promise
// chain and cy.fail needs to know to use the reference to the
Expand Down
10 changes: 3 additions & 7 deletions packages/driver/src/cypress/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,6 @@ const countLogsByTests = function (tests = {}) {
.value()
}

const getCounter = () => {
return counter
}

const setCounter = (num) => {
return counter = num
}
Expand Down Expand Up @@ -181,8 +177,10 @@ const defaults = function (state, config, obj) {
return t._currentRetry || 0
}

counter++

_.defaults(obj, {
id: (counter += 1),
id: `log-${window.location.origin}-${counter}`,
state: 'pending',
instrument: 'command',
url: state('url'),
Expand Down Expand Up @@ -513,8 +511,6 @@ export default {

countLogsByTests,

getCounter,

setCounter,

create (Cypress, cy, state, config) {
Expand Down
111 changes: 111 additions & 0 deletions packages/driver/src/multidomain/communicator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import debugFn from 'debug'
import { EventEmitter } from 'events'

const debug = debugFn('cypress:driver:multi-domain')

const CROSS_DOMAIN_PREFIX = 'cross:domain:'

/**
* Primary domain communicator. Responsible for sending/receiving events throughout
* the driver responsible for multi-domain communication, as well as sending/receiving events to/from the
* spec bridge communicator, respectively.
*
* The 'postMessage' method is used to send events to the spec bridge communicator, while
* the 'message' event is used to receive messages from the spec bridge communicator.
* All events communicating across domains are prefixed with 'cross:domain:' under the hood.
* See https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage for more details.
* @extends EventEmitter
*/
export class PrimaryDomainCommunicator extends EventEmitter {
private windowReference
private crossDomainDriverWindow

/**
* Initializes the event handler to receive messages from the spec bridge.
* @param {Window} win - a reference to the window object in the primary domain.
* @returns {Void}
*/
initialize (win) {
if (this.windowReference) return

this.windowReference = win

this.windowReference.top.addEventListener('message', ({ data, source }) => {
// currently used for tests, can be removed later
if (data?.actual) return

// check if message is cross domain and if so, feed the message into
// the cross domain bus with args and strip prefix
if (data?.event?.includes(CROSS_DOMAIN_PREFIX)) {
const messageName = data.event.replace(CROSS_DOMAIN_PREFIX, '')

// NOTE: need a special case here for 'window:before:load'
// where we need to set the crossDomainDriverWindow to source to
// communicate back to the iframe
if (messageName === 'window:before:load') {
this.crossDomainDriverWindow = source
} else {
this.emit(messageName, data.data)
}

return
}

debug('Unexpected postMessage:', data)
}, false)
}

/**
* Events to be sent to the spec bridge communicator instance.
* @param {string} event - the name of the event to be sent.
* @param {any} data - any meta data to be sent with the event.
*/
toSpecBridge (event: string, data: any) {
this.crossDomainDriverWindow.postMessage({
event,
data,
}, '*')
}
}

/**
* Spec bridge domain communicator. Responsible for sending/receiving events to/from the
* primary domain communicator, respectively.
*
* The 'postMessage' method is used to send events to the primary communicator, while
* the 'message' event is used to receive messages from the primary communicator.
* All events communicating across domains are prefixed with 'cross:domain:' under the hood.
* See https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage for more details.
* @extends EventEmitter
*/
export class SpecBridgeDomainCommunicator extends EventEmitter {
private windowReference

/**
* Initializes the event handler to receive messages from the primary domain.
* @param {Window} win - a reference to the window object in the spec bridge/iframe.
* @returns {Void}
*/
initialize (win) {
if (this.windowReference) return

this.windowReference = win

this.windowReference.addEventListener('message', ({ data }) => {
if (!data) return

this.emit(data.event, data.data)
}, false)
}

/**
* Events to be sent to the primary communicator instance.
* @param {string} event - the name of the event to be sent.
* @param {any} data - any meta data to be sent with the event.
*/
toPrimary (event: string, data: any) {
let prefixedEvent = `${CROSS_DOMAIN_PREFIX}${event}`

this.windowReference.top.postMessage({ event: prefixedEvent, data }, '*')
}
}
Loading

0 comments on commit 388bfa4

Please sign in to comment.