diff --git a/packages/driver/cypress/integration/e2e/multi-domain/multi_domain_yield_spec.ts b/packages/driver/cypress/integration/e2e/multi-domain/multi_domain_yield_spec.ts new file mode 100644 index 000000000000..6bebcf4dd0b0 --- /dev/null +++ b/packages/driver/cypress/integration/e2e/multi-domain/multi_domain_yield_spec.ts @@ -0,0 +1,253 @@ +const { assertLogLength } = require('../../../support/utils') + +// @ts-ignore / session support is needed for visiting about:blank between tests +describe('multi-domain yields', { experimentalSessionSupport: true, experimentalMultiDomain: true }, () => { + let logs: any = [] + + beforeEach(() => { + logs = [] + + cy.on('log:added', (attrs, log) => { + logs.push(log) + }) + + cy.visit('/fixtures/multi-domain.html') + cy.get('a[data-cy="multi-domain-secondary-link"]').click() + }) + + it('yields a value', () => { + cy.switchToDomain('foobar.com', () => { + cy + .get('[data-cy="dom-check"]') + .invoke('text') + }).should('equal', 'From a secondary domain') + }) + + it('yields the cy value even if a return is present', () => { + cy.switchToDomain('foobar.com', () => { + cy + .get('[data-cy="dom-check"]') + .invoke('text') + + const p = new Promise((resolve, reject) => { + setTimeout(() => { + resolve('text') + }, 50) + }) + + return p + }).should('equal', 'From a secondary domain') + }) + + it('errors if a cy command is present and it returns a sync value', (done) => { + cy.on('fail', (err) => { + assertLogLength(logs, 6) + expect(logs[5].get('error')).to.eq(err) + expect(err.message).to.include('`cy.switchToDomain()` failed because you are mixing up async and sync code.') + + done() + }) + + cy.switchToDomain('foobar.com', () => { + cy + .get('[data-cy="dom-check"]') + .invoke('text') + + return 'text' + }) + }) + + it('yields synchronously', () => { + cy.switchToDomain('foobar.com', () => { + return 'From a secondary domain' + }).should('equal', 'From a secondary domain') + }) + + it('yields asynchronously', () => { + cy.switchToDomain('foobar.com', () => { + return new Promise((resolve: (val: string) => any, reject) => { + setTimeout(() => { + resolve('From a secondary domain') + }, 50) + }) + }).should('equal', 'From a secondary domain') + }) + + it('succeeds if subject cannot be serialized and is not accessed synchronously', () => { + cy.switchToDomain('foobar.com', () => { + return { + symbol: Symbol(''), + } + }).then((obj) => { + return 'object not accessed' + }).should('equal', 'object not accessed') + }) + + it('throws if subject cannot be serialized and is accessed synchronously', (done) => { + cy.on('fail', (err) => { + assertLogLength(logs, 6) + expect(logs[5].get('error')).to.eq(err) + expect(err.message).to.include('`cy.switchToDomain()` could not serialize the subject due to one of its properties not being supported by the structured clone algorithm.') + + done() + }) + + cy.switchToDomain('foobar.com', () => { + return { + symbol: Symbol(''), + } + }).then((obj) => { + // This will fail accessing the symbol + // @ts-ignore + return obj.symbol + }) + }) + + it('succeeds if subject cannot be serialized and is not accessed', () => { + cy.switchToDomain('foobar.com', () => { + cy + .get('[data-cy="dom-check"]') + }).then((obj) => { + return 'object not accessed' + }).should('equal', 'object not accessed') + }) + + it('throws if subject cannot be serialized and is accessed', (done) => { + cy.on('fail', (err) => { + assertLogLength(logs, 7) + expect(logs[6].get('error')).to.eq(err) + expect(err.message).to.include('`cy.switchToDomain()` could not serialize the subject due to one of its properties not being supported by the structured clone algorithm.') + + done() + }) + + cy.switchToDomain('foobar.com', () => { + cy + .get('[data-cy="dom-check"]') + }).invoke('text') + .should('equal', 'From a secondary domain') + }) + + it('throws if an object contains a function', (done) => { + cy.on('fail', (err) => { + assertLogLength(logs, 7) + expect(logs[6].get('error')).to.eq(err) + expect(err.message).to.include('`cy.switchToDomain()` could not serialize the subject due to one of its properties not being supported by the structured clone algorithm.') + + done() + }) + + cy.switchToDomain('foobar.com', () => { + cy.wrap({ + key: () => { + return 'whoops' + }, + }) + }).invoke('key').should('equal', 'whoops') + }) + + it('throws if an object contains a symbol', (done) => { + cy.on('fail', (err) => { + assertLogLength(logs, 7) + expect(logs[6].get('error')).to.eq(err) + expect(err.message).to.include('`cy.switchToDomain()` could not serialize the subject due to one of its properties not being supported by the structured clone algorithm.') + + done() + }) + + cy.switchToDomain('foobar.com', () => { + cy.wrap({ + key: Symbol('whoops'), + }) + }).should('equal', undefined) + }) + + it('throws if an object is a function', (done) => { + cy.on('fail', (err) => { + assertLogLength(logs, 7) + expect(logs[6].get('error')).to.eq(err) + expect(err.message).to.include('`cy.switchToDomain()` could not serialize the subject due to functions not being supported by the structured clone algorithm.') + + done() + }) + + cy.switchToDomain('foobar.com', () => { + cy.wrap(() => { + return 'text' + }) + }).then((obj) => { + // @ts-ignore + obj() + }) + }) + + it('throws if an object is a symbol', (done) => { + cy.on('fail', (err) => { + assertLogLength(logs, 7) + expect(logs[6].get('error')).to.eq(err) + expect(err.message).to.include('`cy.switchToDomain()` could not serialize the subject due to symbols not being supported by the structured clone algorithm.') + + done() + }) + + cy.switchToDomain('foobar.com', () => { + cy.wrap(Symbol('symbol')) + }).should('equal', 'symbol') + }) + + // NOTE: Errors can only be serialized on chromium browsers. + it('yields an error if an object contains an error', (done) => { + const isChromium = Cypress.isBrowser({ family: 'chromium' }) + + cy.on('fail', (err) => { + if (!isChromium) { + assertLogLength(logs, 7) + expect(logs[6].get('error')).to.eq(err) + expect(err.message).to.include('`cy.switchToDomain()` could not serialize the subject due to one of its properties not being supported by the structured clone algorithm.') + } + + done() + }) + + cy.switchToDomain('foobar.com', () => { + cy.wrap({ + key: new Error('Boom goes the dynamite'), + }) + }).its('key.message') + .should('equal', 'Boom goes the dynamite').then(() => { + done() + }) + }) + + it('yields an object containing valid types', () => { + cy.switchToDomain('foobar.com', () => { + cy.wrap({ + array: [ + 1, + 2, + ], + undefined, + bool: true, + null: null, + number: 12, + object: { + key: 'key', + }, + string: 'string', + }) + }).should('deep.equal', { + array: [ + 1, + 2, + ], + undefined, + bool: true, + null: null, + number: 12, + object: { + key: 'key', + }, + string: 'string', + }) + }) +}) diff --git a/packages/driver/cypress/integration/util/queue_spec.ts b/packages/driver/cypress/integration/util/queue_spec.ts index c23a23168f0b..ecbd6a52b7d6 100644 --- a/packages/driver/cypress/integration/util/queue_spec.ts +++ b/packages/driver/cypress/integration/util/queue_spec.ts @@ -201,4 +201,16 @@ describe('src/util/queue', () => { expect(queue.stopped).to.false }) }) + + context('.last', () => { + it('returns the last item', () => { + expect(queue.last()).to.deep.equal({ id: '3' }) + + queue.add({ id: '4' }) + expect(queue.last()).to.deep.equal({ id: '4' }) + + queue.clear() + expect(queue.last()).to.equal(undefined) + }) + }) }) diff --git a/packages/driver/src/cy/multi-domain/commands_manager.ts b/packages/driver/src/cy/multi-domain/commands_manager.ts index cdc3e8c49408..8639bf13ac52 100644 --- a/packages/driver/src/cy/multi-domain/commands_manager.ts +++ b/packages/driver/src/cy/multi-domain/commands_manager.ts @@ -1,6 +1,7 @@ import type { PrimaryDomainCommunicator } from '../../multi-domain/communicator' import { createDeferred, Deferred } from '../../util/deferred' import { correctStackForCrossDomainError } from './util' +import { failedToSerializeSubject } from './failedSerializeSubjectProxy' export class CommandsManager { // these are proxy commands that represent real commands in a @@ -57,7 +58,7 @@ export class CommandsManager { Cypress.action('cy:enqueue:command', attrs) } - endCommand = ({ id, name, err, logId }) => { + endCommand = ({ id, subject, failedToSerializeSubjectOfType, name, err, logId }) => { const command = this.commands[id] if (!command) return @@ -65,7 +66,7 @@ export class CommandsManager { delete this.commands[id] if (!err) { - return command.deferred.resolve() + return command.deferred.resolve(failedToSerializeSubjectOfType ? failedToSerializeSubject(failedToSerializeSubjectOfType) : subject) } // If the command has failed, cast the error back to a proper Error object diff --git a/packages/driver/src/cy/multi-domain/failedSerializeSubjectProxy.ts b/packages/driver/src/cy/multi-domain/failedSerializeSubjectProxy.ts new file mode 100644 index 000000000000..1e6376da28dd --- /dev/null +++ b/packages/driver/src/cy/multi-domain/failedSerializeSubjectProxy.ts @@ -0,0 +1,66 @@ +import $errUtils from '../../cypress/error_utils' + +// These properties are required to avoid failing prior to attempting to use the subject. +// If Symbol.toStringTag is passed through to the target we will not properly fail the 'cy.invoke' command. +const passThroughProps = [ + 'then', + Symbol.isConcatSpreadable, + 'jquery', + 'nodeType', + 'window', + 'document', + 'inspect', + 'isSinonProxy', + '_spreadArray', + 'selector', +] + +/** + * Create a proxy object to fail when accessed or called. + * @param type The type of operand that failed to serialize + * @returns A proxy object that will fail when accessed. + */ +const failedToSerializeSubject = (type: string) => { + let target = {} + + // If the failed subject is a function, use a function as the target. + if (type === 'function') { + target = () => {} + } + + // Symbol note: The target can't be a symbol, but we can use an object until the symbol is accessed, then provide a different error. + + return new Proxy(target, { + /** + * Throw an error if the proxy is called like a function. + * @param target the proxy target + * @param thisArg this + * @param argumentsList args passed. + */ + apply (target, thisArg, argumentsList) { + $errUtils.throwErrByPath('switchToDomain.failed_to_serialize_function') + }, + + /** + * Throw an error if any properties besides the listed ones are accessed. + * @param target The proxy target + * @param prop The property being accessed + * @param receiver Either the proxy or an object that inherits from the proxy. + * @returns either an error or the result of the allowed get on the target. + */ + get (target, prop, receiver) { + if (passThroughProps.includes(prop)) { + return target[prop] + } + + // Provide a slightly different message if the object was meant to be a symbol. + if (type === 'symbol') { + $errUtils.throwErrByPath('switchToDomain.failed_to_serialize_symbol') + } else { + $errUtils.throwErrByPath('switchToDomain.failed_to_serialize_object') + } + }, + }) +} + +export { failedToSerializeSubject } diff --git a/packages/driver/src/cy/multi-domain/index.ts b/packages/driver/src/cy/multi-domain/index.ts index d538c954e496..24f11a257ef6 100644 --- a/packages/driver/src/cy/multi-domain/index.ts +++ b/packages/driver/src/cy/multi-domain/index.ts @@ -3,6 +3,7 @@ import $errUtils from '../../cypress/error_utils' import { CommandsManager } from './commands_manager' import { LogsManager } from './logs_manager' import { Validator } from './validator' +import { failedToSerializeSubject } from './failedSerializeSubjectProxy' export function addCommands (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, state: Cypress.State, config: Cypress.InternalConfig) { let timeoutId @@ -118,7 +119,7 @@ export function addCommands (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, logsManager.listen() return new Bluebird((resolve, reject) => { - communicator.once('ran:domain:fn', (err) => { + communicator.once('ran:domain:fn', ({ subject, failedToSerializeSubjectOfType, err }) => { sendReadyForDomain() if (err) { if (done) { @@ -141,9 +142,12 @@ export function addCommands (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, // is common if there are only assertions enqueued in the SD. if (!commandsManager.hasCommands && !done) { cleanup() - } - resolve() + // This handles when a subject is returned synchronously + resolve(failedToSerializeSubjectOfType ? failedToSerializeSubject(failedToSerializeSubjectOfType) : subject) + } else { + resolve() + } }) // If done is NOT passed into switchToDomain, wait for the command queue diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index 92ff3d2c5737..01665e73e251 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -1710,6 +1710,32 @@ export default { This is likely because the data argument specified is not serializable. Note that functions and DOM objects cannot be serialized.`, }, + callback_mixes_sync_and_async: { + message: stripIndent`\ + ${cmd('switchToDomain')} failed because you are mixing up async and sync code. + + In your callback function you invoked one or more cy commands but then returned a synchronous value. + + Cypress commands are asynchronous and it doesn't make sense to queue cy commands and yet return a synchronous value. + + You likely forgot to properly chain the cy commands using another \`cy.then()\`. + + The value you synchronously returned was: \`{{value}}\``, + }, + failed_to_serialize_object: { + message: stripIndent`\ + ${cmd('switchToDomain')} could not serialize the subject due to one of its properties not being supported by the structured clone algorithm. + + To properly serialize this subject, remove or serialize any unsupported properties.`, + }, + failed_to_serialize_function: { + message: stripIndent`\ + ${cmd('switchToDomain')} could not serialize the subject due to functions not being supported by the structured clone algorithm.`, + }, + failed_to_serialize_symbol: { + message: stripIndent`\ + ${cmd('switchToDomain')} could not serialize the subject due to symbols not being supported by the structured clone algorithm.`, + }, }, task: { diff --git a/packages/driver/src/cypress/log.ts b/packages/driver/src/cypress/log.ts index aefb48594ea7..8d963f6628ca 100644 --- a/packages/driver/src/cypress/log.ts +++ b/packages/driver/src/cypress/log.ts @@ -50,7 +50,7 @@ const toSerializedJSON = function (attrs) { return value() } - if (_.isFunction(value)) { + if (_.isFunction(value) || _.isSymbol(value)) { return value.toString() } diff --git a/packages/driver/src/multi-domain/commands.ts b/packages/driver/src/multi-domain/commands.ts index 053a30cd70c0..dff130c36118 100644 --- a/packages/driver/src/multi-domain/commands.ts +++ b/packages/driver/src/multi-domain/commands.ts @@ -13,8 +13,18 @@ export const handleCommands = (Cypress: Cypress.Cypress, cy: $Cy, specBridgeComm const onCommandEnd = (command: Cypress.CommandQueue) => { const id = command.get('id') const name = command.get('name') + const logId = command.getLastLog()?.get('id') - specBridgeCommunicator.toPrimary('command:end', { id, name }) + let subject: string | undefined = undefined + + // Prevent serialization if this isn't the last command in the queue. + if (cy.queue.last()?.get('id') === id) { + subject = cy.state('subject') + } + + // we need to serialize and send back the subject on each command because the next chained + // command outside of the multi-domain context will not wait for the queue finished event. + specBridgeCommunicator.toPrimaryCommandEnd({ id, name, subject, logId }) } const onRunCommand = () => { diff --git a/packages/driver/src/multi-domain/communicator.ts b/packages/driver/src/multi-domain/communicator.ts index 6502a8f6321e..f53989de8b31 100644 --- a/packages/driver/src/multi-domain/communicator.ts +++ b/packages/driver/src/multi-domain/communicator.ts @@ -8,14 +8,14 @@ const debug = debugFn('cypress:driver:multi-domain') const CROSS_DOMAIN_PREFIX = 'cross:domain:' -const serializeForPostMessage = (value) => { +const preprocessErrorForPostMessage = (value) => { const { isDom } = $dom if (_.isError(value)) { - const serializedError = _.mapValues(clone(value), serializeForPostMessage) + const serializableError = _.mapValues(clone(value), preprocessErrorForPostMessage) return { - ... serializedError, + ... serializableError, // Native Error types currently cannot be cloned in Firefox when using 'postMessage'. // Please see https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm for more details name: value.name, @@ -25,7 +25,7 @@ const serializeForPostMessage = (value) => { } if (_.isArray(value)) { - return _.map(value, serializeForPostMessage) + return _.map(value, preprocessErrorForPostMessage) } if (isDom(value)) { @@ -40,7 +40,7 @@ const serializeForPostMessage = (value) => { // clone to nuke circular references // and blow away anything that throws try { - return _.mapValues(clone(value), serializeForPostMessage) + return _.mapValues(clone(value), preprocessErrorForPostMessage) } catch (err) { return null } @@ -125,6 +125,29 @@ export class PrimaryDomainCommunicator extends EventEmitter { export class SpecBridgeDomainCommunicator extends EventEmitter { private windowReference + private handleSubjectAndErr = (event, data) => { + const { subject, err, ...other } = data + + try { + // We always want to make sure errors are posted, so clean it up to send. + const preProcessedError = preprocessErrorForPostMessage(err) + + this.toPrimary(event, { subject, err: preProcessedError, ...other }) + } catch (error: any) { + if (subject && error.name === 'DataCloneError') { + // Send the type of object that failed to serialize. + const failedToSerializeSubjectOfType = typeof subject + + // If the subject threw the 'DataCloneError', the subject cannot be serialized at which point try again with an undefined subject. + this.handleSubjectAndErr(event, { failedToSerializeSubjectOfType, ...other }) + } else { + // Try to send the message again, with the new error. + this.handleSubjectAndErr(event, { err: error, ...other }) + throw error + } + } + } + /** * 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. @@ -147,10 +170,17 @@ export class SpecBridgeDomainCommunicator extends EventEmitter { * @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, serializer?: (data: any) => any) { + toPrimary (event: string, data?: any) { let prefixedEvent = `${CROSS_DOMAIN_PREFIX}${event}` - data = serializer ? serializer(data) : serializeForPostMessage(data) this.windowReference.top.postMessage({ event: prefixedEvent, data }, '*') } + + toPrimaryCommandEnd (data: {id: string, subject?: any, name: string, err?: any, logId: string }) { + this.handleSubjectAndErr('command:end', data) + } + + toPrimaryRanDomainFn (data: { subject?: any, err?: any }) { + this.handleSubjectAndErr('ran:domain:fn', data) + } } diff --git a/packages/driver/src/multi-domain/domain_fn.ts b/packages/driver/src/multi-domain/domain_fn.ts index 19ac3ee531e2..e1b378b51ce7 100644 --- a/packages/driver/src/multi-domain/domain_fn.ts +++ b/packages/driver/src/multi-domain/domain_fn.ts @@ -1,5 +1,7 @@ import type { $Cy } from '../cypress/cy' import type { SpecBridgeDomainCommunicator } from './communicator' +import $errUtils from '../cypress/error_utils' +import $utils from '../cypress/utils' export const handleDomainFn = (cy: $Cy, specBridgeCommunicator: SpecBridgeDomainCommunicator) => { const doneEarly = () => { @@ -79,18 +81,27 @@ export const handleDomainFn = (cy: $Cy, specBridgeCommunicator: SpecBridgeDomain const name = command.get('name') const logId = command.getLastLog()?.get('id') - specBridgeCommunicator.toPrimary('command:end', { id, name, err, logId }) + specBridgeCommunicator.toPrimaryCommandEnd({ id, name, err, logId }) }) try { // await the eval func, whether it is a promise or not // we should not need to transpile this as our target browsers support async/await // see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function for more details - await window.eval(fnWrapper)(data) + const value = window.eval(fnWrapper)(data) - specBridgeCommunicator.toPrimary('ran:domain:fn') + // If we detect a non promise value with commands in queue, throw an error + if (value && cy.queue.length > 0 && !value.then) { + $errUtils.throwErrByPath('switchToDomain.callback_mixes_sync_and_async', { + args: { value: $utils.stringify(value) }, + }) + } else { + const subject = await value + + specBridgeCommunicator.toPrimaryRanDomainFn({ subject }) + } } catch (err) { - specBridgeCommunicator.toPrimary('ran:domain:fn', err) + specBridgeCommunicator.toPrimaryRanDomainFn({ err }) } finally { cy.state('done', undefined) } diff --git a/packages/driver/src/multi-domain/logs.ts b/packages/driver/src/multi-domain/logs.ts index fa4f28470728..e2eec9de8e55 100644 --- a/packages/driver/src/multi-domain/logs.ts +++ b/packages/driver/src/multi-domain/logs.ts @@ -4,11 +4,11 @@ import $Log from '../cypress/log' export const handleLogs = (Cypress: Cypress.Cypress, specBridgeCommunicator: SpecBridgeDomainCommunicator) => { const onLogAdded = (attrs) => { - specBridgeCommunicator.toPrimary('log:added', attrs, $Log.toSerializedJSON) + specBridgeCommunicator.toPrimary('log:added', $Log.toSerializedJSON(attrs)) } const onLogChanged = (attrs) => { - specBridgeCommunicator.toPrimary('log:changed', attrs, $Log.toSerializedJSON) + specBridgeCommunicator.toPrimary('log:changed', $Log.toSerializedJSON(attrs)) } Cypress.on('log:added', onLogAdded) diff --git a/packages/driver/src/util/queue.ts b/packages/driver/src/util/queue.ts index 4a1564979d3f..21fa8aacc9e2 100644 --- a/packages/driver/src/util/queue.ts +++ b/packages/driver/src/util/queue.ts @@ -104,4 +104,16 @@ export class Queue { get stopped () { return this._stopped } + + /** + * Helper function to return the last item in the queue. + * @returns The last item or undefined if the queue is empty. + */ + last (): T | undefined { + if (this.length < 1) { + return undefined + } + + return this.at(this.length - 1) + } }