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

feat: [Multi-domain]: Yield subject from switchToDomain #19936

Merged
merged 25 commits into from
Feb 3, 2022

Conversation

mjhenkes
Copy link
Member

User facing changelog

n/a

Additional details

With this change we are yielding the last subject returned by the last run cy command or the return statement of synchronous execution. If the subject or return cannot be serialized we return undefined.

How has the user experience changed?

Users may now write tests like this

    it('yields a value', () => {
      cy.switchToDomain('foobar.com', () => {
        cy
        .get('[data-cy="dom-check"]')
        .invoke('text')
      }).should('equal', 'From a secondary domain')
    })

PR Tasks

  • Have tests been added/updated?
  • Has the original issue (or this PR, if no issue exists) been tagged with a release in ZenHub? (user-facing changes only)
  • Has a PR for user-facing changes been opened in cypress-documentation?
  • Have API changes been updated in the type definitions?
  • Have new configuration options been added to the cypress.schema.json?

@mjhenkes mjhenkes requested a review from a team as a code owner January 27, 2022 21:04
@mjhenkes mjhenkes requested review from jennifer-shehane and removed request for a team January 27, 2022 21:04
@cypress-bot
Copy link
Contributor

cypress-bot bot commented Jan 27, 2022

Thanks for taking the time to open a PR!

@mjhenkes mjhenkes requested review from chrisbreiding, AtofStryker and mschile and removed request for jennifer-shehane January 27, 2022 21:04
* @param value any object to be serialized.
* @returns the serialized object as a string or undefined, if the object cannot be serialized.
*/
const serialize = (value: any): string => {
Copy link
Member Author

Choose a reason for hiding this comment

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

@AtofStryker, I know that you put together some level of serialization for the errors but this seemed different enough? Perhaps I should rename this subjectSerializer?

Copy link
Contributor

Choose a reason for hiding this comment

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

I just saw this comment after review. Yeah I think that is a good idea to distinguish between the two! I have a feeling we are going to have to address serialization pretty soon since it sounds like we are going to have quite a few and might need to see where we can consolidate on.

try {
serializedSubject = JSON.stringify(value, (key, value) => {
// If we encounter any unserializable values we want to abort the whole process.
if (isFunction(value) || isSymbol(value) || value === undefined) {
Copy link
Member Author

Choose a reason for hiding this comment

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

We can't port undefined using json, even trying to sub it out for our own custom string would require us to write our own parse function. I could transform undefined values into null but that may be confusing.

Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if this is more of a user experience type of thing. I wonder if assigning null on purpose would tell the end user that null was intentionally assigned because we couldn't serialize their yield. But that also might be confusing.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think we probably need to discuss this with Ben and Brian. We talked about making unserialized values undefined, but not about objects that might have unserializable values within them, so I think we need to define that behavior. I'm not sure if throwing away the whole object is the right way to go or not.

* @returns the deserialized object
*/
const deserialize = (value: string|undefined): string|object|boolean|number|null|any[] => {
return value ? JSON.parse(value) : value
Copy link
Member Author

Choose a reason for hiding this comment

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

this code is pretty simple but i liked pairing a deserialize function with my serialize function.

@@ -50,7 +50,7 @@ const toSerializedJSON = function (attrs) {
return value()
}

if (_.isFunction(value)) {
if (_.isFunction(value) || _.isSymbol(value)) {
Copy link
Member Author

Choose a reason for hiding this comment

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

This fixes an error where we're trying to log out an object containing a symbol


resolve()
resolve(deserializedSubject)
Copy link
Member Author

Choose a reason for hiding this comment

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

Here is where we resolve anything returned from the switchToDomain function. I'm not sure when it makes sense to synchronously return something from switchToDomain, but i'm matching the functionality of the then function blocks.

return command.deferred.resolve()
const deserializedSubject = deserialize(subject)

return command.deferred.resolve(deserializedSubject)
Copy link
Member Author

Choose a reason for hiding this comment

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

Here we're returning the last yielded subject from the last run cy command.

@@ -14,7 +15,11 @@ export const handleCommands = (Cypress: Cypress.Cypress, cy: $Cy, specBridgeComm
const id = command.get('id')
const name = command.get('name')

specBridgeCommunicator.toPrimary('command:end', { id, name })
let serializedSubject = serialize(cy.state('subject'))
Copy link
Member Author

Choose a reason for hiding this comment

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

It might be possible to avoid doing this if we aren't the last command in the queue. I'll investigate that.

Copy link
Member Author

Choose a reason for hiding this comment

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

I could do this, is this crazy? We're getting the last command in the queue and comparing ids

Suggested change
let serializedSubject = serialize(cy.state('subject'))
let serializedSubject: string | undefined = undefined
if (cy.queue.at(cy.queue.length - 1).get('id') === id) {
serializedSubject = serialize(cy.state('subject'))
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Do we ever need to yield something back that isn't the last command? I don't think it's crazy and it could save on serialization costs if other commands are yielding subjects.

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like a good idea to me. Let's go ahead and add a cy.queue.last() method or even a cy.queue.isLast(id) method.

@mjhenkes mjhenkes added the topic: cy.origin Problems or enhancements related to cy.origin command label Jan 27, 2022
@mjhenkes mjhenkes changed the title [Multi-domain]: Yield subject from switchToDomain feat: [Multi-domain]: Yield subject from switchToDomain Jan 27, 2022
Copy link
Contributor

@AtofStryker AtofStryker left a comment

Choose a reason for hiding this comment

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

Just a few small comments and suggestions. Looking good!

})
}).should('equal', undefined)
})

Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if we want to do something similar with Errors here since they aren't serializable through the structured clone algorithm in Firefox. Since we already attempt to serialize errors to objects and back to errors in the primary, I wonder if we want to leverage the util function here in the serializer.

Or we can also just return undefined if an error is yielded to be consistent with the rest of the unserializable attributes. I'm also not sure why someone would want to yield an error 🤷‍♂️ .

it('yields undefined if an object contains an error', () => {
  cy.switchToDomain('foobar.com', () => {
    cy.wrap({
      key: new Error('Boom goes the dynamite'),
    })
  }).should('equal', undefined)
})

Copy link
Contributor

Choose a reason for hiding this comment

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

I would expect an unserializable values to be undefined, not the whole object itself. So I would think the 3 above tests would yield an empty object ({}) instead of undefined. What happens if there are other serializable values in the object like { key: undefined, otherKey: 'defined' }?

* @param value a string
* @returns the deserialized object
*/
const deserialize = (value: string|undefined): string|object|boolean|number|null|any[] => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
const deserialize = (value: string|undefined): string|object|boolean|number|null|any[] => {
const deserializeSubject = (value: string|undefined): string|object|boolean|number|null|any[] => {

* @param value any object to be serialized.
* @returns the serialized object as a string or undefined, if the object cannot be serialized.
*/
const serialize = (value: any): string | undefined => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Since we now have multiple serializers going on, should we prefix this to make the functions intent clearer?

Suggested change
const serialize = (value: any): string | undefined => {
const serializeSubject = (value: any): string | undefined => {

try {
serializedSubject = JSON.stringify(value, (key, value) => {
// If we encounter any unserializable values we want to abort the whole process.
if (isFunction(value) || isSymbol(value) || value === undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Total nitpick, but more of a readability thing here. I think this also allows for void operator support, but I wouldn't think that would be applicable here.

Suggested change
if (isFunction(value) || isSymbol(value) || value === undefined) {
if (isFunction(value) || isSymbol(value) || isUndefined(value)) {

Copy link
Contributor

Choose a reason for hiding this comment

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

We also might need to add isError here as well.

try {
serializedSubject = JSON.stringify(value, (key, value) => {
// If we encounter any unserializable values we want to abort the whole process.
if (isFunction(value) || isSymbol(value) || value === undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if this is more of a user experience type of thing. I wonder if assigning null on purpose would tell the end user that null was intentionally assigned because we couldn't serialize their yield. But that also might be confusing.

packages/driver/src/multi-domain/commands.ts Outdated Show resolved Hide resolved

specBridgeCommunicator.toPrimary('ran:domain:fn')
specBridgeCommunicator.toPrimary('ran:domain:fn', { subject: serialize(subject) })
Copy link
Contributor

Choose a reason for hiding this comment

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

does leveraging the serializer argument in toPrimary work here?

Suggested change
specBridgeCommunicator.toPrimary('ran:domain:fn', { subject: serialize(subject) })
specBridgeCommunicator.toPrimary('ran:domain:fn', { subject }, serialize)

Copy link
Member Author

Choose a reason for hiding this comment

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

No, but i should make it work, i just realized I'm doing double serialization

@@ -14,7 +15,11 @@ export const handleCommands = (Cypress: Cypress.Cypress, cy: $Cy, specBridgeComm
const id = command.get('id')
const name = command.get('name')

specBridgeCommunicator.toPrimary('command:end', { id, name })
let serializedSubject = serialize(cy.state('subject'))
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we ever need to yield something back that isn't the last command? I don't think it's crazy and it could save on serialization costs if other commands are yielding subjects.

Co-authored-by: Bill Glesias <bglesias@gmail.com>
Comment on lines 291 to 299
it('yields the cy value even if a return is present', () => {
cy.switchToDomain('foobar.com', () => {
cy
.get('[data-cy="dom-check"]')
.invoke('text')

return 'text'
}).should('equal', 'From a secondary domain')
})
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 require some discussion, but I would expect this to behave similar to .then, where we throw an error in this case, since it's probably not what the user expects the yielded value to be.

  cy
  .visit('/test.html')
  .then(() => {
    cy.get('h1').invoke('text')

    return 'text'
  })
  .should('equal', 'text')

results in this error:

Screen Shot 2022-01-28 at 3 41 34 PM

Copy link
Member Author

Choose a reason for hiding this comment

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

That makes sense, I'll look into it

})
}).should('equal', undefined)
})

Copy link
Contributor

Choose a reason for hiding this comment

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

I would expect an unserializable values to be undefined, not the whole object itself. So I would think the 3 above tests would yield an empty object ({}) instead of undefined. What happens if there are other serializable values in the object like { key: undefined, otherKey: 'defined' }?

@@ -14,7 +15,11 @@ export const handleCommands = (Cypress: Cypress.Cypress, cy: $Cy, specBridgeComm
const id = command.get('id')
const name = command.get('name')

specBridgeCommunicator.toPrimary('command:end', { id, name })
let serializedSubject = serialize(cy.state('subject'))
Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like a good idea to me. Let's go ahead and add a cy.queue.last() method or even a cy.queue.isLast(id) method.

try {
serializedSubject = JSON.stringify(value, (key, value) => {
// If we encounter any unserializable values we want to abort the whole process.
if (isFunction(value) || isSymbol(value) || value === undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we probably need to discuss this with Ben and Brian. We talked about making unserialized values undefined, but not about objects that might have unserializable values within them, so I think we need to define that behavior. I'm not sure if throwing away the whole object is the right way to go or not.

mjhenkes and others added 2 commits February 2, 2022 11:53
@AtofStryker AtofStryker self-requested a review February 2, 2022 18:04

cy.switchToDomain('foobar.com', () => {
cy.wrap({
key: new Error('Boom goes the dynamite'),
Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Out of scope here, but I am also wondering if we should do something similar with test errors. In other words, only try to preprocess the error if the browser can't serialize it through postMessage. Otherwise, just send it as is as opposed to always preprocessing the error.

*/
get (target, prop, receiver) {
// These properties are required to avoid failing prior to attempting to use the subject.
if (
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we refactor this to a list on constants like const unserializableSubjects = ['then', 'jquery', ...] and do something like unserializableSubjects.includes(prop) since we are working with strings? It might make this a bit easier to read and maintain in the future.

Comment on lines 145 to 150
// This handles when a subject is returned synchronously
if (failedToSerializeSubjectOfType) {
return resolve(
failedToSerializeSubject(failedToSerializeSubjectOfType),
)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Total nit/preference here. I'm going to apologize for the formatting in this suggestion in advance 😅

Suggested change
// This handles when a subject is returned synchronously
if (failedToSerializeSubjectOfType) {
return resolve(
failedToSerializeSubject(failedToSerializeSubjectOfType),
)
}
// This handles when a subject is returned synchronously
const serializedSubject = failedToSerializeSubjectOfType ?
failedToSerializeSubject(failedToSerializeSubjectOfType) :
subject
resolve(serializedSubject)

Copy link
Member Author

Choose a reason for hiding this comment

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

I updated with a single line variant fo this suggestion

const { isDom } = $dom

if (_.isError(value)) {
const serializedError = _.mapValues(clone(value), serializeForPostMessage)
const serializedError = _.mapValues(clone(value), preprocessErrorForPostMessage)
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we still want to call this a serializedError? I was almost thinking processedError but that almost sounds more confusing 😅

Suggested change
const serializedError = _.mapValues(clone(value), preprocessErrorForPostMessage)
const processedError = _.mapValues(clone(value), preprocessErrorForPostMessage)

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, if we are using this for only errors now and not generic serialization, I wonder if we can gut like 90% of this function and just imply that value is of type Error or if its undefined just do nothing?

Copy link
Member Author

Choose a reason for hiding this comment

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

I went with serializableError. I do wonder what we could trim out from this function, but haven't dug into what could be in error that needs to be serialized since we do call this function recursively to serialize anything in error.

packages/driver/src/multi-domain/domain_fn.ts Show resolved Hide resolved
@@ -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: {
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if something like callback_mixes_sync_and_commands is more appropriate? Not entirely sure 🤔

@chrisbreiding chrisbreiding self-requested a review February 3, 2022 15:15
@AtofStryker AtofStryker self-requested a review February 3, 2022 15:20
cy
.get('[data-cy="dom-check"]')
}).invoke('text')
.should('equal', 'From a secondary domain')
Copy link
Contributor

Choose a reason for hiding this comment

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

You can probably remove these shoulds now since they aren't getting hit.

Copy link
Contributor

Choose a reason for hiding this comment

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

I kind of like having them there to show how a user might think this test works, but doesn't. Almost like a documentation artifact.

…y.ts

Co-authored-by: Matt Schile <mschile@gmail.com>
Copy link
Contributor

@AtofStryker AtofStryker left a comment

Choose a reason for hiding this comment

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

🚀

cy
.get('[data-cy="dom-check"]')
}).invoke('text')
.should('equal', 'From a secondary domain')
Copy link
Contributor

Choose a reason for hiding this comment

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

I kind of like having them there to show how a user might think this test works, but doesn't. Almost like a documentation artifact.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
topic: cy.origin Problems or enhancements related to cy.origin command
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants