diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index e2f84f11780e..80c1f362a125 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -610,8 +610,8 @@ export class EventManager { }) // Reflect back to the requesting origin the status of the 'duringUserTestExecution' state - Cypress.primaryOriginCommunicator.on('sync:during:user:test:execution', ({ specBridgeResponseEvent }, origin) => { - Cypress.primaryOriginCommunicator.toSpecBridge(origin, specBridgeResponseEvent, cy.state('duringUserTestExecution')) + Cypress.primaryOriginCommunicator.on('sync:during:user:test:execution', (_data, { origin, responseEvent }) => { + Cypress.primaryOriginCommunicator.toSpecBridge(origin, responseEvent, cy.state('duringUserTestExecution')) }) Cypress.on('request:snapshot:from:spec:bridge', ({ log, name, options, specBridge, addSnapshot }: { @@ -624,9 +624,13 @@ export class EventManager { const eventID = log.get('id') const requestSnapshot = () => { - return Cypress.primaryOriginCommunicator.toSpecBridgePromise(specBridge, 'snapshot:generate:for:log', { - name, - id: eventID, + return Cypress.primaryOriginCommunicator.toSpecBridgePromise({ + origin: specBridge, + event: 'snapshot:generate:for:log', + data: { + name, + id: eventID, + }, }).then((crossOriginSnapshot) => { const snapshot = crossOriginSnapshot.body ? crossOriginSnapshot : null @@ -656,7 +660,7 @@ export class EventManager { this.localBus.emit('expect:origin', origin) }) - Cypress.primaryOriginCommunicator.on('viewport:changed', (viewport, origin) => { + Cypress.primaryOriginCommunicator.on('viewport:changed', (viewport, { origin }) => { const callback = () => { Cypress.primaryOriginCommunicator.toSpecBridge(origin, 'viewport:changed:end') } @@ -665,7 +669,7 @@ export class EventManager { this.localBus.emit('viewport:changed', viewport, callback) }) - Cypress.primaryOriginCommunicator.on('before:screenshot', (config, origin) => { + Cypress.primaryOriginCommunicator.on('before:screenshot', (config, { origin }) => { const callback = () => { Cypress.primaryOriginCommunicator.toSpecBridge(origin, 'before:screenshot:end') } @@ -710,6 +714,26 @@ export class EventManager { }, ) + /** + * Call a backend request for the requesting spec bridge since we cannot have websockets in the spec bridges. + * Return it's response. + */ + Cypress.primaryOriginCommunicator.on('backend:request', async ({ args }, { source, responseEvent }) => { + const response = await Cypress.backend(...args) + + Cypress.primaryOriginCommunicator.toSource(source, responseEvent, response) + }) + + /** + * Call an automation request for the requesting spec bridge since we cannot have websockets in the spec bridges. + * Return it's response. + */ + Cypress.primaryOriginCommunicator.on('automation:request', async ({ args }, { source, responseEvent }) => { + const response = await Cypress.automation(...args) + + Cypress.primaryOriginCommunicator.toSource(source, responseEvent, response) + }) + // The window.top should not change between test reloads, and we only need to bind the message event when Cypress is recreated // Forward all message events to the current instance of the multi-origin communicator if (!window.top) throw new Error('missing window.top in event-manager') @@ -848,7 +872,7 @@ export class EventManager { notifyCrossOriginBridgeReady (origin) { // Any multi-origin event appends the origin as the third parameter and we do the same here for this short circuit - Cypress.primaryOriginCommunicator.emit('bridge:ready', undefined, origin) + Cypress.primaryOriginCommunicator.emit('bridge:ready', undefined, { origin }) } snapshotUnpinned () { diff --git a/packages/driver/cypress/e2e/e2e/origin/commands/navigation.cy.ts b/packages/driver/cypress/e2e/e2e/origin/commands/navigation.cy.ts index 3119226458d1..87cf42e2f94a 100644 --- a/packages/driver/cypress/e2e/e2e/origin/commands/navigation.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/commands/navigation.cy.ts @@ -453,7 +453,8 @@ context('cy.origin navigation', { browser: '!webkit' }, () => { }) }) - it('.go()', () => { + // TODO: Investigate this flaky test. + it('.go()', { retries: 15 }, () => { cy.visit('/fixtures/primary-origin.html') cy.get('a[data-cy="cross-origin-secondary-link"]').click() diff --git a/packages/driver/cypress/e2e/e2e/origin/origin.cy.ts b/packages/driver/cypress/e2e/e2e/origin/origin.cy.ts index 9f200bd79e8a..c76c2f174dff 100644 --- a/packages/driver/cypress/e2e/e2e/origin/origin.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/origin.cy.ts @@ -1,4 +1,15 @@ describe('cy.origin', { browser: '!webkit' }, () => { + it('successfully visits after creating 30 spec bridges', () => { + // Make ~30 spec bridges + for (let index = 0; index < 30; index++) { + cy.origin(`http://www.${index}.com:3500`, () => undefined) + } + + cy.origin('http://www.app.foobar.com:3500', () => { + cy.visit('/fixtures/primary-origin.html') + }) + }) + it('passes viewportWidth/Height state to the secondary origin', () => { const expectedViewport = [320, 480] diff --git a/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts b/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts index d917b1fdfabe..1c806d4f6210 100644 --- a/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts @@ -1,4 +1,5 @@ -describe('src/cross-origin/patches', { browser: '!webkit' }, () => { +// Stubbing increases the time taken to make a backend request call, so we increase the default command timeout to avoid flake. +describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout: 10000 }, () => { context('submit', () => { beforeEach(() => { cy.visit('/fixtures/primary-origin.html') @@ -62,9 +63,7 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { describe('from the AUT', () => { beforeEach(() => { cy.intercept('/test-request').as('testRequest') - cy.origin('http://www.foobar.com:3500', () => { - cy.stub(Cypress, 'backend').callThrough() - }) + cy.stub(Cypress, 'backend').callThrough() cy.visit('/fixtures/primary-origin.html') cy.get('a[data-cy="xhr-fetch-requests"]').click() @@ -80,18 +79,18 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { cy.origin('http://www.foobar.com:3500', { args: { postfixedSelector, - assertCredentialStatus, }, }, - ({ postfixedSelector, assertCredentialStatus }) => { + ({ postfixedSelector }) => { cy.get(`[data-cy="trigger-fetch${postfixedSelector}"]`).click() - cy.wait('@testRequest') - cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { - url: 'http://www.foobar.com:3500/test-request', - resourceType: 'fetch', - credentialStatus: assertCredentialStatus, - }) + }) + + cy.wait('@testRequest') + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request', + resourceType: 'fetch', + credentialStatus: assertCredentialStatus, }) }) }) @@ -100,18 +99,18 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { cy.origin('http://www.foobar.com:3500', { args: { postfixedSelector, - assertCredentialStatus, }, }, - ({ postfixedSelector, assertCredentialStatus }) => { + ({ postfixedSelector }) => { cy.get(`[data-cy="trigger-fetch-with-request-object${postfixedSelector}"]`).click() - cy.wait('@testRequest') - cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { - url: 'http://www.foobar.com:3500/test-request', - resourceType: 'fetch', - credentialStatus: assertCredentialStatus, - }) + }) + + cy.wait('@testRequest') + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request', + resourceType: 'fetch', + credentialStatus: assertCredentialStatus, }) }) }) @@ -120,18 +119,18 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { cy.origin('http://www.foobar.com:3500', { args: { postfixedSelector, - assertCredentialStatus, }, }, - ({ postfixedSelector, assertCredentialStatus }) => { + ({ postfixedSelector }) => { cy.get(`[data-cy="trigger-fetch-with-url-object${postfixedSelector}"]`).click() - cy.wait('@testRequest') - cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { - url: 'http://www.foobar.com:3500/test-request', - resourceType: 'fetch', - credentialStatus: assertCredentialStatus, - }) + }) + + cy.wait('@testRequest') + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request', + resourceType: 'fetch', + credentialStatus: assertCredentialStatus, }) }) }) @@ -139,35 +138,45 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { }) it('fails gracefully if fetch is called with Bad arguments and we don\'t single to the socket (must match the fetch api spec), but fetch request still proceeds', () => { - cy.origin('http://www.foobar.com:3500', + cy.origin( + 'http://www.foobar.com:3500', () => { cy.on('uncaught:exception', (err) => { expect(err.message).to.contain('404') - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') return false }) cy.get(`[data-cy="trigger-fetch-with-bad-options"]`).click() - }) + }, + ) + + cy.then(() => { + expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + }) }) it('works as expected with requests that require preflight that ultimately fail and the request does not succeed', () => { - cy.origin('http://www.foobar.com:3500', + cy.origin( + 'http://www.foobar.com:3500', () => { cy.on('uncaught:exception', (err) => { expect(err.message).to.contain('CORS ERROR') - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { - url: 'http://app.foobar.com:3500/test-request', - resourceType: 'fetch', - credentialStatus: 'include', - }) return false }) cy.get(`[data-cy="trigger-fetch-with-preflight"]`).click() + }, + ) + + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://app.foobar.com:3500/test-request', + resourceType: 'fetch', + credentialStatus: 'include', }) + }) }) }) }) @@ -176,9 +185,6 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { beforeEach(() => { cy.intercept('/test-request').as('testRequest') cy.stub(Cypress, 'backend').callThrough() - cy.origin('http://www.foobar.com:3500', () => { - cy.stub(Cypress, 'backend').callThrough() - }) cy.visit('/fixtures/primary-origin.html') cy.get('a[data-cy="xhr-fetch-requests"]').click() @@ -196,9 +202,8 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { cy.origin('http://www.foobar.com:3500', { args: { credentialOption, - assertCredentialStatus, }, - }, ({ credentialOption, assertCredentialStatus }) => { + }, ({ credentialOption }) => { cy.then(() => { if (credentialOption) { return fetch('http://www.foobar.com:3500/test-request-credentials', { @@ -208,13 +213,13 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { return fetch('http://www.foobar.com:3500/test-request-credentials') }) + }) - cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { - url: 'http://www.foobar.com:3500/test-request-credentials', - resourceType: 'fetch', - credentialStatus: assertCredentialStatus, - }) + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request-credentials', + resourceType: 'fetch', + credentialStatus: assertCredentialStatus, }) }) }) @@ -223,9 +228,8 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { cy.origin('http://www.foobar.com:3500', { args: { credentialOption, - assertCredentialStatus, }, - }, ({ credentialOption, assertCredentialStatus }) => { + }, ({ credentialOption }) => { cy.then(() => { let req @@ -239,13 +243,13 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { return fetch(req) }) + }) - cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { - url: 'http://www.foobar.com:3500/test-request-credentials', - resourceType: 'fetch', - credentialStatus: assertCredentialStatus, - }) + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request-credentials', + resourceType: 'fetch', + credentialStatus: assertCredentialStatus, }) }) }) @@ -254,9 +258,8 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { cy.origin('http://www.foobar.com:3500', { args: { credentialOption, - assertCredentialStatus, }, - }, ({ credentialOption, assertCredentialStatus }) => { + }, ({ credentialOption }) => { cy.then(() => { let urlObj = new URL('/test-request-credentials', 'http://www.foobar.com:3500') @@ -268,13 +271,13 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { return fetch(urlObj) }) + }) - cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { - url: 'http://www.foobar.com:3500/test-request-credentials', - resourceType: 'fetch', - credentialStatus: assertCredentialStatus, - }) + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request-credentials', + resourceType: 'fetch', + credentialStatus: assertCredentialStatus, }) }) }) @@ -286,13 +289,16 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { () => { cy.on('uncaught:exception', (err) => { expect(err.message).to.contain('404') - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') return false }) cy.get(`[data-cy="trigger-fetch-with-bad-options"]`).click() }) + + cy.then(() => { + expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + }) }) }) @@ -309,12 +315,6 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { 'foo': 'bar', }, }).catch(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { - url: 'http://app.foobar.com:3500/test-request', - resourceType: 'fetch', - credentialStatus: 'include', - }) - resolve() }).then(() => { // if this fetch does not fail, fail the test @@ -323,6 +323,32 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { }) }) }) + + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://app.foobar.com:3500/test-request', + resourceType: 'fetch', + credentialStatus: 'include', + }) + }) + }) + }) + + it('patches prior to attaching to a spec bridge', () => { + // manually remove the spec bridge iframe to ensure Cypress.state('window') is not already set + window.top?.document.getElementById('Spec\ Bridge:\ foobar.com')?.remove() + + cy.stub(Cypress, 'backend').callThrough() + + cy.visit('/fixtures/primary-origin.html') + cy.get('a[data-cy="xhr-fetch-requests-onload"]').click() + + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://localhost:3500/foo.bar.baz.json', + resourceType: 'fetch', + credentialStatus: 'same-origin', + }) }) }) @@ -360,9 +386,7 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { describe('from the AUT', () => { beforeEach(() => { cy.intercept('/test-request').as('testRequest') - cy.origin('http://www.foobar.com:3500', () => { - cy.stub(Cypress, 'backend').callThrough() - }) + cy.stub(Cypress, 'backend').callThrough() cy.visit('/fixtures/primary-origin.html') cy.get('a[data-cy="xhr-fetch-requests"]').click() @@ -376,35 +400,38 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { cy.origin('http://www.foobar.com:3500', { args: { postfixedSelector, - withCredentials, }, }, - ({ postfixedSelector, withCredentials = false }) => { + ({ postfixedSelector = false }) => { cy.get(`[data-cy="trigger-xml-http-request${postfixedSelector}"]`).click() - cy.wait('@testRequest') - cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { - url: 'http://www.foobar.com:3500/test-request', - resourceType: 'xhr', - credentialStatus: withCredentials, - }) + }) + + cy.wait('@testRequest') + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request', + resourceType: 'xhr', + credentialStatus: withCredentials, }) }) }) }) - it('fails gracefully if xhr is called with Bad arguments and we don\'t signal to the socket (must match the legit URL), but xhr request still proceeds', () => { + it('still emits credential status in the case absolute url can be parsed even though request results in 404', () => { cy.origin('http://www.foobar.com:3500', () => { cy.on('uncaught:exception', (err) => { expect(err.message).to.contain('404') - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') return false }) cy.get(`[data-cy="trigger-xml-http-request-with-bad-options"]`).click() }) + + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWithMatch('request:sent:with:credentials') + }) }) }) @@ -413,17 +440,20 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { () => { cy.on('uncaught:exception', (err) => { expect(err.message).to.contain('CORS ERROR') - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { - url: 'http://app.foobar.com:3500/test-request', - resourceType: 'xhr', - credentialStatus: true, - }) return false }) cy.get(`[data-cy="trigger-xml-http-request-with-preflight"]`).click() }) + + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://app.foobar.com:3500/test-request', + resourceType: 'xhr', + credentialStatus: true, + }) + }) }) }) @@ -467,30 +497,33 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { xhr.send() }) }) + }) - cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { - url: 'http://www.foobar.com:3500/test-request-credentials', - resourceType: 'xhr', - credentialStatus: withCredentials, - }) + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request-credentials', + resourceType: 'xhr', + credentialStatus: withCredentials, }) }) }) }) - it('fails gracefully if xmlHttpRequest is called with bad arguments and we don\'t signal to the socket (must match the legit URL), but xhr request still proceeds', () => { + it('still emits credential status in the case absolute url can be parsed even though request results in 404', () => { cy.origin('http://www.foobar.com:3500', () => { cy.on('uncaught:exception', (err) => { expect(err.message).to.contain('404') - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') return false }) cy.get(`[data-cy="trigger-xml-http-request-with-bad-options"]`).click() }) + + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWithMatch('request:sent:with:credentials') + }) }) it('works as expected with requests that require preflight that ultimately fail and the request does not succeed', () => { @@ -510,12 +543,6 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { } xhr.onerror = function () { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { - url: 'http://app.foobar.com:3500/test-request', - resourceType: 'xhr', - credentialStatus: true, - }) - resolve() } @@ -523,6 +550,32 @@ describe('src/cross-origin/patches', { browser: '!webkit' }, () => { }) }) }) + + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://app.foobar.com:3500/test-request', + resourceType: 'xhr', + credentialStatus: true, + }) + }) + }) + }) + }) + + it('patches prior to attaching to a spec bridge', () => { + // manually remove the spec bridge iframe to ensure Cypress.state('window') is not already set + window.top?.document.getElementById('Spec\ Bridge:\ foobar.com')?.remove() + + cy.stub(Cypress, 'backend').callThrough() + + cy.visit('/fixtures/primary-origin.html') + cy.get('a[data-cy="xhr-fetch-requests-onload"]').click() + + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://localhost:3500/foo.bar.baz.json', + resourceType: 'xhr', + credentialStatus: false, }) }) }) diff --git a/packages/driver/cypress/e2e/e2e/origin/validation.cy.ts b/packages/driver/cypress/e2e/e2e/origin/validation.cy.ts index 8cc7320fffa4..ba15a29cb2f4 100644 --- a/packages/driver/cypress/e2e/e2e/origin/validation.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/validation.cy.ts @@ -1,12 +1,5 @@ describe('cy.origin', { browser: '!webkit' }, () => { describe('successes', () => { - beforeEach(() => { - // TODO: There seems to be a limit of 15 active spec bridges during a given test. - // Needs to be fixed with https://github.com/cypress-io/cypress/issues/22874 - // @ts-ignore - [...window.top?.document.getElementsByClassName('spec-bridge-iframe')].forEach((el) => el.remove()) - }) - it('succeeds on a localhost domain name', () => { cy.origin('localhost', () => undefined) cy.then(() => { diff --git a/packages/driver/src/cross-origin/communicator.ts b/packages/driver/src/cross-origin/communicator.ts index ae7ec206983d..2bb6af60355c 100644 --- a/packages/driver/src/cross-origin/communicator.ts +++ b/packages/driver/src/cross-origin/communicator.ts @@ -19,28 +19,27 @@ const SNAPSHOT_EVENT_PREFIX = `${CROSS_ORIGIN_PREFIX}snapshot:` * @param event the name of the event to be promisified. * @param specBridgeName the name of the spec bridge receiving the event. * @param communicator the communicator that is sending the message + * @param [timeout=1000] - in ms, if the promise does not complete during this timeout, fail the promise. * @returns the data to send */ const sharedPromiseSetup = ({ resolve, reject, - data, event, specBridgeName, communicator, + timeout = 1000, }: { resolve: Function reject: Function - data?: any event: string specBridgeName: string communicator: EventEmitter + timeout: number }) => { let timeoutId - const dataToSend = data || {} - - dataToSend.specBridgeResponseEvent = `${event}:${Date.now()}` + const responseEvent = `${event}:${Date.now()}` const handler = (result) => { clearTimeout(timeoutId) @@ -48,13 +47,13 @@ const sharedPromiseSetup = ({ } timeoutId = setTimeout(() => { - communicator.off(dataToSend.specBridgeResponseEvent, handler) - reject(new Error(`${event} failed to receive a response from ${specBridgeName} spec bridge within 1 second.`)) - }, 1000) + communicator.off(responseEvent, handler) + reject(new Error(`${event} failed to receive a response from ${specBridgeName} spec bridge within ${timeout / 1000} second.`)) + }, timeout) - communicator.once(dataToSend.specBridgeResponseEvent, handler) + communicator.once(responseEvent, handler) - return dataToSend + return responseEvent } /** @@ -105,7 +104,7 @@ export class PrimaryOriginCommunicator extends EventEmitter { data.data.err = reifySerializedError(data.data.err, this.userInvocationStack as string) } - this.emit(messageName, data.data, data.origin, source) + this.emit(messageName, data.data, { origin: data.origin, source, responseEvent: data.responseEvent }) return } @@ -114,7 +113,7 @@ export class PrimaryOriginCommunicator extends EventEmitter { } /** - * Events to be sent to the spec bridge communicator instance. + * Sends an event to all spec bridge communicator instances. * @param {string} event - the name of the event to be sent. * @param {any} data - any meta data to be sent with the event. */ @@ -137,9 +136,30 @@ export class PrimaryOriginCommunicator extends EventEmitter { }) } - toSpecBridge (origin: string, event: string, data?: any) { + /** + * Sends an event to a specific spec bridge. + * @param origin - the origin of the spec bridge to send the event to. + * @param event - the name of the event to be sent. + * @param data - any meta data to be sent with the event. + * @param responseEvent - the event to be responded with when sending back a result. + */ + toSpecBridge (origin: string, event: string, data?: any, responseEvent?: string) { debug('=> to spec bridge', origin, event, data) + const source = this.crossOriginDriverWindows[origin] + + if (source) { + this.toSource(source, event, data, responseEvent) + } + } + /** + * Sends an event to a specific source. + * @param source - a reference to the window object that sent the message. + * @param event - the name of the event to be sent. + * @param data - any meta data to be sent with the event. + * @param responseEvent - the event to be responded with when sending back a result. + */ + toSource (source: Window, event: string, data?: any, responseEvent?: string) { const preprocessedData = preprocessForSerialization(data) // if user defined arguments are passed in, do NOT sanitize them. @@ -148,9 +168,10 @@ export class PrimaryOriginCommunicator extends EventEmitter { } // If there is no crossOriginDriverWindows, there is no need to send the message. - this.crossOriginDriverWindows[origin]?.postMessage({ + source.postMessage({ event, data: preprocessedData, + responseEvent, }, '*') } @@ -159,20 +180,31 @@ export class PrimaryOriginCommunicator extends EventEmitter { * @param {string} event - the name of the event to be sent. * @param {Cypress.ObjectLike} data - any meta data to be sent with the event. * @param options - contains boolean to sync globals + * @param [timeout=1000] - in ms, if the promise does not complete during this timeout, fail the promise. * @returns the response from primary of the event with the same name. */ - toSpecBridgePromise (origin: string, event: string, data?: any) { + toSpecBridgePromise ({ + origin, + event, + data, + timeout = 1000, + }: { + origin: string + event: string + data?: any + timeout: number + }) { return new Promise((resolve, reject) => { - const dataToSend = sharedPromiseSetup({ + const responseEvent = sharedPromiseSetup({ resolve, reject, - data, event, specBridgeName: origin, communicator: this, + timeout, }) - this.toSpecBridge(origin, event, dataToSend) + this.toSpecBridge(origin, event, data, responseEvent) }) } } @@ -242,15 +274,16 @@ export class SpecBridgeCommunicator extends EventEmitter { onMessage ({ data }) { if (!data) return - this.emit(data.event, data.data) + this.emit(data.event, data.data, { responseEvent: data.responseEvent }) } /** * Events to be sent to the primary communicator instance. * @param {string} event - the name of the event to be sent. * @param {Cypress.ObjectLike} data - any meta data to be sent with the event. + * @param responseEvent - the event to be responded with when sending back a result. */ - toPrimary (event: string, data?: Cypress.ObjectLike, options: { syncGlobals: boolean } = { syncGlobals: false }) { + toPrimary (event: string, data?: Cypress.ObjectLike, options: { syncGlobals: boolean } = { syncGlobals: false }, responseEvent?: string) { const { origin } = $Location.create(window.location.href) const eventName = `${CROSS_ORIGIN_PREFIX}${event}` @@ -273,6 +306,7 @@ export class SpecBridgeCommunicator extends EventEmitter { event: eventName, data, origin, + responseEvent, }, '*') }) } @@ -281,20 +315,31 @@ export class SpecBridgeCommunicator extends EventEmitter { * @param {string} event - the name of the event to be sent. * @param {Cypress.ObjectLike} data - any meta data to be sent with the event. * @param options - contains boolean to sync globals + * @param [timeout=1000] - in ms, if the promise does not complete during this timeout, fail the promise. * @returns the response from primary of the event with the same name. */ - toPrimaryPromise (event: string, data?: Cypress.ObjectLike, options: { syncGlobals: boolean } = { syncGlobals: false }) { + toPrimaryPromise ({ + event, + data, + options = { syncGlobals: false }, + timeout = 1000, + }: { + event: string + data?: Cypress.ObjectLike + options: {syncGlobals: boolean} + timeout: number + }) { return new Promise((resolve, reject) => { - const dataToSend = sharedPromiseSetup({ + const responseEvent = sharedPromiseSetup({ resolve, reject, - data, event, specBridgeName: 'the primary Cypress', communicator: this, + timeout, }) - this.toPrimary(event, dataToSend, options) + this.toPrimary(event, data, options, responseEvent) }) } } diff --git a/packages/driver/src/cross-origin/cypress.ts b/packages/driver/src/cross-origin/cypress.ts index c529ea8cdea9..ee206d43416b 100644 --- a/packages/driver/src/cross-origin/cypress.ts +++ b/packages/driver/src/cross-origin/cypress.ts @@ -21,8 +21,9 @@ import { handleTestEvents } from './events/test' import { handleMiscEvents } from './events/misc' import { handleUnsupportedAPIs } from './unsupported_apis' import { patchFormElementSubmit } from './patches/submit' -import { patchFetch } from './patches/fetch' -import { patchXmlHttpRequest } from './patches/xmlHttpRequest' +import { patchFetch } from '@packages/runner/injection/patches/fetch' +import { patchXmlHttpRequest } from '@packages/runner/injection/patches/xmlHttpRequest' + import $Mocha from '../cypress/mocha' const createCypress = () => { @@ -60,6 +61,9 @@ const createCypress = () => { const autWindow = findWindow() + // If Cypress is present on the autWindow, it has already been attached + // This commonly happens if the spec bridge was created in a prior to + // running this specific instance of the cy.origin command. if (autWindow && !autWindow.Cypress) { attachToWindow(autWindow) } @@ -77,7 +81,7 @@ const createCypress = () => { } }) - Cypress.specBridgeCommunicator.on('snapshot:generate:for:log', ({ name, specBridgeResponseEvent }) => { + Cypress.specBridgeCommunicator.on('snapshot:generate:for:log', ({ name }, { responseEvent }) => { // if the snapshot cannot be taken (in a transitory space), set to an empty object in order to not fail serialization let requestedCrossOriginSnapshot = {} @@ -87,7 +91,7 @@ const createCypress = () => { requestedCrossOriginSnapshot = cy.createSnapshot(name) || {} } - Cypress.specBridgeCommunicator.toPrimary(specBridgeResponseEvent, requestedCrossOriginSnapshot) + Cypress.specBridgeCommunicator.toPrimary(responseEvent, requestedCrossOriginSnapshot) }) Cypress.specBridgeCommunicator.toPrimary('bridge:ready') @@ -158,7 +162,7 @@ const attachToWindow = (autWindow: Window) => { // this communicates to the injection code that Cypress is now available so // it can safely subscribe to Cypress events, etc // @ts-ignore - autWindow.__attachToCypress(Cypress) + autWindow.__attachToCypress ? autWindow.__attachToCypress(Cypress) : undefined Cypress.state('window', autWindow) Cypress.state('document', autWindow.document) @@ -174,11 +178,8 @@ const attachToWindow = (autWindow: Window) => { // place after override incase fetch is polyfilled in the AUT injection // this can be in the beforeLoad code as we only want to patch fetch/xmlHttpRequest // when the cy.origin block is active to track credential use - patchFetch(Cypress, autWindow) - patchXmlHttpRequest(Cypress, autWindow) - // also patch it in the spec bridge as well - patchFetch(Cypress, window) - patchXmlHttpRequest(Cypress, window) + patchFetch(window) + patchXmlHttpRequest(window) // TODO: DRY this up with the mostly-the-same code in src/cypress/cy.js // https://github.com/cypress-io/cypress/issues/20972 diff --git a/packages/driver/src/cross-origin/events/socket.ts b/packages/driver/src/cross-origin/events/socket.ts index 05663ff13d0e..e74058cfb714 100644 --- a/packages/driver/src/cross-origin/events/socket.ts +++ b/packages/driver/src/cross-origin/events/socket.ts @@ -1,18 +1,16 @@ -import { createWebsocket } from '@packages/socket/lib/browser' - export const handleSocketEvents = (Cypress) => { - const webSocket = createWebsocket({ path: Cypress.config('socketIoRoute'), browserFamily: Cypress.config('browser').family }) - - webSocket.connect() - - const onBackendRequest = (...args) => { - webSocket.emit('backend:request', ...args) - } + const onRequest = async (event, args) => { + // The last argument is the callback, pop that off before messaging primary and call it with the response. + const callback = args.pop() + const response = await Cypress.specBridgeCommunicator.toPrimaryPromise({ + event, + data: { args }, + timeout: Cypress.config().defaultCommandTimeout, + }) - const onAutomationRequest = (...args) => { - webSocket.emit('automation:request', ...args) + callback({ response }) } - Cypress.on('backend:request', onBackendRequest) - Cypress.on('automation:request', onAutomationRequest) + Cypress.on('backend:request', (...args) => onRequest('backend:request', args)) + Cypress.on('automation:request', (...args) => onRequest('automation:request', args)) } diff --git a/packages/driver/src/cross-origin/patches/utils/index.ts b/packages/driver/src/cross-origin/patches/utils/index.ts deleted file mode 100644 index cb3d5aacc83c..000000000000 --- a/packages/driver/src/cross-origin/patches/utils/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const captureFullRequestUrl = (relativeOrAbsoluteUrlString: string, window: Window) => { - // need to pass the window here by reference to generate the correct absolute URL if needed. Spec Bridge does NOT contain sub domain - let url - - try { - url = new URL(relativeOrAbsoluteUrlString).toString() - } catch (err1) { - try { - // likely a relative path, construct the full url - url = new URL(relativeOrAbsoluteUrlString, window.location.origin).toString() - } catch (err2) { - return undefined - } - } - - return url -} diff --git a/packages/driver/src/cy/commands/navigation.ts b/packages/driver/src/cy/commands/navigation.ts index 5e1aec95db4a..4b797d253b62 100644 --- a/packages/driver/src/cy/commands/navigation.ts +++ b/packages/driver/src/cy/commands/navigation.ts @@ -277,7 +277,9 @@ const stabilityChanged = async (Cypress, state, config, stable) => { // We need to sync this state value prior to checking it otherwise we will erroneously log a loading event after the test is complete. if (Cypress.isCrossOriginSpecBridge) { - const duringUserTestExecution = await Cypress.specBridgeCommunicator.toPrimaryPromise('sync:during:user:test:execution') + const duringUserTestExecution = await Cypress.specBridgeCommunicator.toPrimaryPromise({ + event: 'sync:during:user:test:execution', + }) cy.state('duringUserTestExecution', duringUserTestExecution) } diff --git a/packages/driver/src/cy/commands/origin/index.ts b/packages/driver/src/cy/commands/origin/index.ts index e1519579d58f..5853131b4a1e 100644 --- a/packages/driver/src/cy/commands/origin/index.ts +++ b/packages/driver/src/cy/commands/origin/index.ts @@ -168,7 +168,7 @@ export default (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, state: State } // fired once the spec bridge is set up and ready to receive messages - communicator.once('bridge:ready', async (_data, specBridgeOrigin) => { + communicator.once('bridge:ready', async (_data, { origin: specBridgeOrigin }) => { if (specBridgeOrigin === origin) { // now that the spec bridge is ready, instantiate Cypress with the current app config and environment variables for initial sync when creating the instance communicator.toSpecBridge(origin, 'initialize:cypress', { diff --git a/packages/driver/src/cy/commands/waiting.ts b/packages/driver/src/cy/commands/waiting.ts index 964723af5a57..cf711fa50452 100644 --- a/packages/driver/src/cy/commands/waiting.ts +++ b/packages/driver/src/cy/commands/waiting.ts @@ -282,7 +282,7 @@ export default (Commands, Cypress, cy, state) => { }) } - Cypress.primaryOriginCommunicator.on('wait:for:xhr', ({ args: [str, options] }, origin) => { + Cypress.primaryOriginCommunicator.on('wait:for:xhr', ({ args: [str, options] }, { origin }) => { options.isCrossOriginSpecBridge = true waitString(null, str, options).then((responses) => { Cypress.primaryOriginCommunicator.toSpecBridge(origin, 'wait:for:xhr:end', responses) diff --git a/packages/runner/injection/cross-origin.js b/packages/runner/injection/cross-origin.js index 9b44a3420dcf..297402360c32 100644 --- a/packages/runner/injection/cross-origin.js +++ b/packages/runner/injection/cross-origin.js @@ -13,6 +13,8 @@ import { createTimers } from './timers' import { patchDocumentCookie } from './patches/cookies' import { patchElementIntegrity } from './patches/setAttribute' +import { patchFetch } from './patches/fetch' +import { patchXmlHttpRequest } from './patches/xmlHttpRequest' const findCypress = () => { for (let index = 0; index < window.parent.frames.length; index++) { @@ -50,22 +52,17 @@ window.addEventListener('beforeunload', () => { // This error could also be handled by creating and attaching a spec bridge and re-throwing the error. // If this approach proves to be an issue we could try the new solution. const handleErrorEvent = (event) => { - if (window.Cypress) { - // A spec bridge has attached so we don't need to forward errors to top anymore. - window.removeEventListener('error', handleErrorEvent) - } else { - const { error } = event - const data = { href: window.location.href } - - if (error && error.stack && error.message) { - data.message = error.message - data.stack = error.stack - } else { - data.message = error - } + const { error } = event + const data = { href: window.location.href } - window.top.postMessage({ event: 'cross:origin:aut:throw:error', data }, '*') + if (error && error.stack && error.message) { + data.message = error.message + data.stack = error.stack + } else { + data.message = error } + + window.top.postMessage({ event: 'cross:origin:aut:throw:error', data }, '*') } window.addEventListener('error', handleErrorEvent) @@ -73,8 +70,6 @@ window.addEventListener('error', handleErrorEvent) // Apply Patches const documentCookiePatch = patchDocumentCookie(cypressConfig.simulatedCookies) -const Cypress = findCypress() - // return null to trick contentWindow into thinking // its not been iFramed if modifyObstructiveCode is true if (cypressConfig.modifyObstructiveCode) { @@ -94,7 +89,16 @@ const timers = createTimers() timers.wrap() -const attachToCypress = (Cypress) => { +// Attach these to window so cypress can call them when it attaches. +// Patched to track credentials use. +patchFetch(window) +patchXmlHttpRequest(window) + +// Add a function to window for the spec bridge to call after it has attached. +window.__attachToCypress = (Cypress) => { + // A spec bridge has attached so we don't need to forward errors to top anymore. + window.removeEventListener('error', handleErrorEvent) + documentCookiePatch.onCypress(Cypress) Cypress.removeAllListeners('app:timers:reset') @@ -102,20 +106,15 @@ const attachToCypress = (Cypress) => { Cypress.on('app:timers:reset', timers.reset) Cypress.on('app:timers:pause', timers.pause) -} -// if the page loaded before creating a spec bridge for it, this method will -// be called, letting us know we can utilize window.Cypress. we can skip this -// if we already have access to window.Cypress -window.__attachToCypress = (asyncAttachedCypress) => { - if (!Cypress) { - attachToCypress(asyncAttachedCypress) - } + // This function will self destruct + delete window.__attachToCypress } +const Cypress = findCypress() + // Check for cy too to prevent a race condition for attaching. if (Cypress && Cypress.cy) { - attachToCypress(Cypress) - + window.__attachToCypress(Cypress) Cypress.action('app:window:before:load', window) } diff --git a/packages/driver/src/cross-origin/patches/fetch.ts b/packages/runner/injection/patches/fetch.ts similarity index 71% rename from packages/driver/src/cross-origin/patches/fetch.ts rename to packages/runner/injection/patches/fetch.ts index 28596d35d420..fd7fbdf3e0ac 100644 --- a/packages/driver/src/cross-origin/patches/fetch.ts +++ b/packages/runner/injection/patches/fetch.ts @@ -1,16 +1,16 @@ -import { captureFullRequestUrl } from './utils' +import { captureFullRequestUrl, requestSentWithCredentials } from './utils' -export const patchFetch = (Cypress: Cypress.Cypress, window) => { +export const patchFetch = (window) => { // if fetch is available in the browser, or is polyfilled by whatwg fetch // intercept method calls and add cypress headers to determine cookie applications in the proxy // for simulated top. @see https://github.github.io/fetch/ for default options - if (!Cypress.config('experimentalSessionAndOrigin') || !window.fetch) { + if (!window.fetch) { return } const originalFetch = window.fetch - window.fetch = function (...args) { + window.fetch = async function (...args) { try { let url: string | undefined = undefined let credentials: string | undefined = undefined @@ -25,7 +25,7 @@ export const patchFetch = (Cypress: Cypress.Cypress, window) => { url = resource.toString() ;({ credentials } = args[1] || {}) - } else if (Cypress._.isString(resource)) { + } else if (typeof resource === 'string') { url = captureFullRequestUrl(resource, window) ;({ credentials } = args[1] || {}) @@ -34,15 +34,11 @@ export const patchFetch = (Cypress: Cypress.Cypress, window) => { credentials = credentials || 'same-origin' // if the option is specified, communicate it to the the server to the proxy can make the request aware if it needs to potentially apply cross origin cookies // if the option isn't set, we can imply the default as we know the resource type in the proxy - if (url) { - // @ts-expect-error - Cypress.backend('request:sent:with:credentials', { - // TODO: might need to go off more information here or at least make collisions less likely - url, - resourceType: 'fetch', - credentialStatus: credentials, - }) - } + await requestSentWithCredentials({ + url, + resourceType: 'fetch', + credentialStatus: credentials, + }) } finally { // if our internal logic errors for whatever reason, do NOT block the end user and continue the request return originalFetch.apply(this, args) diff --git a/packages/runner/injection/patches/utils/index.ts b/packages/runner/injection/patches/utils/index.ts new file mode 100644 index 000000000000..c9cc50942a0f --- /dev/null +++ b/packages/runner/injection/patches/utils/index.ts @@ -0,0 +1,82 @@ +export const captureFullRequestUrl = (relativeOrAbsoluteUrlString: string, window: Window) => { + // need to pass the window here by reference to generate the correct absolute URL if needed. Spec Bridge does NOT contain sub domain + let url + + try { + url = new URL(relativeOrAbsoluteUrlString).toString() + } catch (err1) { + try { + // likely a relative path, construct the full url + url = new URL(relativeOrAbsoluteUrlString, window.location.origin).toString() + } catch (err2) { + return undefined + } + } + + return url +} + +const CROSS_ORIGIN_PREFIX = 'cross:origin:' + +/** + * Sets up a promisified post message + * @param data - the data to send + * @param event - the name of the event to be promisified. + * @param timeout - in ms, if the promise does not complete during this timeout, fail the promise. + * @returns the data to send + */ +export const postMessagePromise = ({ event, data = {}, timeout }: {event: string, data: any, timeout: number}): Promise => { + return new Promise((resolve, reject) => { + const eventName = `${CROSS_ORIGIN_PREFIX}${event}` + let timeoutId + + const responseEvent = `${eventName}:${Date.now()}` + + const handler = (event) => { + if (event.data.event === responseEvent) { + window.removeEventListener('message', handler) + clearTimeout(timeoutId) + resolve(event.data.data) + } + } + + timeoutId = setTimeout(() => { + window.removeEventListener('message', handler) + reject(new Error(`${eventName} failed to receive a response from the primary cypress instance within ${timeout / 1000} second.`)) + }, timeout) + + window.addEventListener('message', handler) + + window.top?.postMessage({ + event: eventName, + data, + responseEvent, + }, '*') + }) +} + +/** + * Returns a promise from the backend request for the 'request:sent:with:credentials' event. + * @param args - an object containing a url, resourceType and Credential status. + * @returns A Promise or null depending on the url parameter. + */ +export const requestSentWithCredentials = (args: {url?: string, resourceType: 'xhr' | 'fetch', credentialStatus: string | boolean}): Promise | undefined => { + if (args.url) { + // If cypress is enabled on the window use that, otherwise use post message to call out to the primary cypress instance. + // cypress may be found on the window if this is either the primary cypress instance or if a spec bridge has already been created for this spec bridge. + if (window.Cypress) { + //@ts-expect-error + return Cypress.backend('request:sent:with:credentials', args) + } + + return postMessagePromise({ + event: 'backend:request', + data: { + args: ['request:sent:with:credentials', args], + }, + timeout: 2000, + }) + } + + return +} diff --git a/packages/driver/src/cross-origin/patches/xmlHttpRequest.ts b/packages/runner/injection/patches/xmlHttpRequest.ts similarity index 65% rename from packages/driver/src/cross-origin/patches/xmlHttpRequest.ts rename to packages/runner/injection/patches/xmlHttpRequest.ts index 92cb07b204b5..14a02a6f55d4 100644 --- a/packages/driver/src/cross-origin/patches/xmlHttpRequest.ts +++ b/packages/runner/injection/patches/xmlHttpRequest.ts @@ -1,13 +1,9 @@ -import { captureFullRequestUrl } from './utils' +import { captureFullRequestUrl, requestSentWithCredentials } from './utils' -export const patchXmlHttpRequest = (Cypress: Cypress.Cypress, window: Window) => { +export const patchXmlHttpRequest = (window: Window) => { // intercept method calls and add cypress headers to determine cookie applications in the proxy // for simulated top - if (!Cypress.config('experimentalSessionAndOrigin')) { - return - } - const originalXmlHttpRequestOpen = window.XMLHttpRequest.prototype.open const originalXmlHttpRequestSend = window.XMLHttpRequest.prototype.send @@ -21,19 +17,15 @@ export const patchXmlHttpRequest = (Cypress: Cypress.Cypress, window: Window) => } } - window.XMLHttpRequest.prototype.send = function (...args) { + window.XMLHttpRequest.prototype.send = async function (...args) { try { // if the option is specified, communicate it to the the server to the proxy can make the request aware if it needs to potentially apply cross origin cookies // if the option isn't set, we can imply the default as we know the resource type in the proxy - if (this._url) { - // @ts-expect-error - Cypress.backend('request:sent:with:credentials', { - // TODO: might need to go off more information here or at least make collisions less likely - url: this._url, - resourceType: 'xhr', - credentialStatus: this.withCredentials, - }) - } + await requestSentWithCredentials({ + url: this._url, + resourceType: 'xhr', + credentialStatus: this.withCredentials, + }) } finally { // if our internal logic errors for whatever reason, do NOT block the end user and continue the request return originalXmlHttpRequestSend.apply(this, args)