diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 6009b776873..4f69396c252 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -10,6 +10,7 @@ _Released 12/16/2025 (PENDING)_ **Bugfixes:** - Fixed an issue where a EPIPE error shows up after CTRL+C is done in terminal. Fixes [#30659](https://github.com/cypress-io/cypress/issues/30659). Addressed in [#32873](https://github.com/cypress-io/cypress/pull/32873). +- Fixed an issue where the browser would freeze when Cypress intercepts a synchronous XHR request and a `routeHandler` is used. Fixes [#32874](https://github.com/cypress-io/cypress/issues/32874). Addressed in [#32925](https://github.com/cypress-io/cypress/pull/32925). ## 15.7.1 diff --git a/packages/data-context/schemas/schema.graphql b/packages/data-context/schemas/schema.graphql index adf7cfa627d..72dac0a8698 100644 --- a/packages/data-context/schemas/schema.graphql +++ b/packages/data-context/schemas/schema.graphql @@ -1224,6 +1224,9 @@ enum ErrorTypeEnum { SETUP_NODE_EVENTS_INVALID_EVENT_NAME_ERROR SETUP_NODE_EVENTS_IS_NOT_FUNCTION SUPPORT_FILE_NOT_FOUND + SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_APPLIED + SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_SET + SYNCHRONOUS_XHR_REQUEST_NOT_INTERCEPTED TESTING_TYPE_NOT_CONFIGURED TESTS_DID_NOT_START_FAILED TESTS_DID_NOT_START_RETRYING diff --git a/packages/driver/cypress/e2e/commands/net_stubbing.cy.ts b/packages/driver/cypress/e2e/commands/net_stubbing.cy.ts index ac9394d132a..17323dfcf64 100644 --- a/packages/driver/cypress/e2e/commands/net_stubbing.cy.ts +++ b/packages/driver/cypress/e2e/commands/net_stubbing.cy.ts @@ -335,6 +335,147 @@ describe('network stubbing', { retries: 15 }, function () { }) }) + it('does not intercept an XHR sync request with a route handler', () => { + cy.intercept('/', { + body: ` + + + +
+
+ + + + `, + }) + + // this intercept won't get hit because the request is sync + cy.intercept('/fixtures/valid.json', () => {}).as('sync') + + // this intercept will get hit because the request is async + cy.intercept('/async', (req) => { + req.reply({ body: 'async' }) + }).as('async') + + cy.visit('/') + cy.get('#sync_response').should('contain', '{"foo":1,"bar":{"baz":"cypress"}}') + cy.get('#async_response').should('contain', 'async') + cy.wait('@async') + }) + + it('does not intercept a cross-origin XHR sync request with a route handler', { browser: '!webkit' }, () => { + cy.intercept('http://www.foobar.com:3500/', { + body: ` + + + +
+
+ + + + `, + }) + + // this intercept won't get hit because the request is sync + cy.intercept('http://www.foobar.com:3500/fixtures/valid.json', (req) => { + req.reply({ body: '' }) + }) + + // this intercept will get hit because the request is async + cy.intercept('http://www.foobar.com:3500/async', (req) => { + req.reply({ body: 'async' }) + }).as('async') + + cy.origin('http://www.foobar.com:3500', () => { + cy.visit('http://www.foobar.com:3500/') + cy.get('#sync_response').should('contain', '{"foo":1,"bar":{"baz":"cypress"}}') + cy.get('#async_response').should('contain', 'async') + }) + + cy.wait('@async') + }) + + it('intercepts a sync XHR request from a Worker with a route handler', () => { + cy.intercept('/', { + body: ` + + + +
+
+ + + + `, + }) + + // this intercept will get hit because the sync XHR request is sent from the worker + cy.intercept('http://localhost:3500/fixtures/valid.json', (req) => { + req.reply({ body: '{"foo":"bar"}' }) + }).as('sync') + + // this intercept will get hit because the request is async + cy.intercept('http://localhost:3500/async', (req) => { + req.reply({ body: 'async' }) + }).as('async') + + cy.visit('/') + cy.get('#sync_response').should('contain', '{"foo":"bar"}') + cy.get('#async_response').should('contain', 'async') + cy.wait('@sync') + cy.wait('@async') + }) + context('overrides', function () { it('chains middleware with string matcher as expected', function () { const e: string[] = [] diff --git a/packages/driver/cypress/e2e/e2e/origin/cookie_misc.cy.ts b/packages/driver/cypress/e2e/e2e/origin/cookie_misc.cy.ts index e6d9259f279..6676a4ba4c1 100644 --- a/packages/driver/cypress/e2e/e2e/origin/cookie_misc.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/cookie_misc.cy.ts @@ -38,6 +38,86 @@ describe('misc cookie tests', { browser: '!webkit' }, () => { }) }) + it('cookies are not set for XHR sync requests', { browser: '!webkit' }, () => { + // this intercept won't get hit because the request is sync + cy.intercept('http://www.foobar.com:3500/set-cookie*', (req) => { + req.reply({ + headers: { + 'set-cookie': 'SYNC_COOKIE=sync', + }, + body: '', + }) + }) + + cy.intercept('http://www.foobar.com:3500/async', { + headers: { + 'set-cookie': 'ASYNC_COOKIE=async', + 'content-type': 'application/json', + }, + body: JSON.stringify({ foo: 1, bar: 2 }), + }).as('async') + + cy.visit('http://localhost:3500/fixtures/empty.html') + cy.origin('http://www.foobar.com:3500', () => { + cy.visit('http://www.foobar.com:3500/') + cy.window().then((win) => { + const xhr = new win.XMLHttpRequest() + + xhr.open('GET', '/set-cookie?cookie=foo=bar', false) + xhr.send() + + const xhr2 = new win.XMLHttpRequest() + + xhr2.open('GET', '/async', true) + xhr2.send() + }) + }) + + // cy.getAllCookies does not wait for the cookies to be set, so we need to wait manually + cy.wait(500) + + cy.getAllCookies().then((cookies) => { + const isFirefox = Cypress.isBrowser({ family: 'firefox' }) + const asyncCookie = { + name: 'ASYNC_COOKIE', + value: 'async', + path: '/', + secure: false, + hostOnly: true, + httpOnly: false, + domain: 'www.foobar.com', + sameSite: isFirefox ? 'unspecified' : 'lax', + } + + const fooBarCookie = { + name: 'foo', + value: 'bar', + path: '/', + secure: false, + hostOnly: true, + httpOnly: false, + domain: 'www.foobar.com', + sameSite: isFirefox ? 'unspecified' : 'lax', + } + + if (isFirefox) { + // in Firefox both the foo=bar and ASYNC_COOKIE=async cookies will be set + // SYNC_COOKIE=sync is not set because the intercept is not hit + expect(cookies).to.have.length(2) + expect(cookies[0]).to.deep.equal(fooBarCookie) + expect(cookies[1]).to.deep.equal(asyncCookie) + } else { + // in other browsers only the ASYNC_COOKIE=async cookie will be set + // SYNC_COOKIE=sync is not set because the intercept is not hit + // foo=bar is not set because the request is sync and we are not able to sync the cookie with the automation + expect(cookies).to.have.length(1) + expect(cookies[0]).to.deep.equal(asyncCookie) + } + }) + + cy.wait('@async') + }) + /** * FIXES: * https://github.com/cypress-io/cypress/issues/25205 (cookies set with expired time with value deleted show up as set with value deleted) diff --git a/packages/driver/src/cross-origin/cypress.ts b/packages/driver/src/cross-origin/cypress.ts index f6db7dd58ec..1e919aab643 100644 --- a/packages/driver/src/cross-origin/cypress.ts +++ b/packages/driver/src/cross-origin/cypress.ts @@ -22,8 +22,8 @@ import { handleTestEvents } from './events/test' import { handleMiscEvents } from './events/misc' import { handleUnsupportedAPIs } from './unsupported_apis' import { patchFormElementSubmit } from './patches/submit' -import { patchFetch } from '@packages/runner/injection/patches/fetch' -import { patchXmlHttpRequest } from '@packages/runner/injection/patches/xmlHttpRequest' +import { patchFetch } from '@packages/runner/injection/patches/cross-origin/fetch' +import { patchXmlHttpRequest } from '@packages/runner/injection/patches/cross-origin/xmlHttpRequest' import $Mocha from '../cypress/mocha' diff --git a/packages/errors/src/errors.ts b/packages/errors/src/errors.ts index 20ad76079fe..36cc234e40c 100644 --- a/packages/errors/src/errors.ts +++ b/packages/errors/src/errors.ts @@ -1561,6 +1561,21 @@ export const AllCypressErrors = { ${fmt.highlightSecondary(error)} ` }, + SYNCHRONOUS_XHR_REQUEST_NOT_INTERCEPTED: (url: string) => { + return errTemplate`\ + Warning: Synchronous XHR request was not intercepted: ${fmt.url(url)}. Learn more: ${fmt.url('https://on.cypress.io/synchronous-xhr-requests')} + ` + }, + SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_APPLIED: (url: string) => { + return errTemplate`\ + Warning: Cookies may not have been applied to synchronous XHR request: ${fmt.url(url)}. Learn more: ${fmt.url('https://on.cypress.io/synchronous-xhr-requests')} + ` + }, + SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_SET: (url: string) => { + return errTemplate`\ + Warning: Cookies may not have been set for synchronous XHR response: ${fmt.url(url)}. Learn more: ${fmt.url('https://on.cypress.io/synchronous-xhr-requests')} + ` + }, } as const const _typeCheck: Record ErrTemplateResult> = AllCypressErrors diff --git a/packages/errors/test/__snapshots__/SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_APPLIED.ansi b/packages/errors/test/__snapshots__/SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_APPLIED.ansi new file mode 100644 index 00000000000..44cbfaf2368 --- /dev/null +++ b/packages/errors/test/__snapshots__/SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_APPLIED.ansi @@ -0,0 +1,2 @@ +Warning: Cookies may not have been applied to synchronous XHR request: http://localhost:8080. Learn more: https://on.cypress.io/synchronous-xhr-requests + \ No newline at end of file diff --git a/packages/errors/test/__snapshots__/SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_SET.ansi b/packages/errors/test/__snapshots__/SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_SET.ansi new file mode 100644 index 00000000000..d62125d2ee3 --- /dev/null +++ b/packages/errors/test/__snapshots__/SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_SET.ansi @@ -0,0 +1,2 @@ +Warning: Cookies may not have been set for synchronous XHR response: http://localhost:8080. Learn more: https://on.cypress.io/synchronous-xhr-requests + \ No newline at end of file diff --git a/packages/errors/test/__snapshots__/SYNCHRONOUS_XHR_REQUEST_NOT_INTERCEPTED.ansi b/packages/errors/test/__snapshots__/SYNCHRONOUS_XHR_REQUEST_NOT_INTERCEPTED.ansi new file mode 100644 index 00000000000..1558376730f --- /dev/null +++ b/packages/errors/test/__snapshots__/SYNCHRONOUS_XHR_REQUEST_NOT_INTERCEPTED.ansi @@ -0,0 +1,2 @@ +Warning: Synchronous XHR request was not intercepted: http://localhost:8080. Learn more: https://on.cypress.io/synchronous-xhr-requests + \ No newline at end of file diff --git a/packages/errors/test/visualSnapshotErrors.spec.ts b/packages/errors/test/visualSnapshotErrors.spec.ts index 3814e874a3c..e477af50707 100644 --- a/packages/errors/test/visualSnapshotErrors.spec.ts +++ b/packages/errors/test/visualSnapshotErrors.spec.ts @@ -1121,5 +1121,20 @@ describe('visual error templates', () => { default: [], } }, + SYNCHRONOUS_XHR_REQUEST_NOT_INTERCEPTED: () => { + return { + default: ['http://localhost:8080'], + } + }, + SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_APPLIED: () => { + return { + default: ['http://localhost:8080'], + } + }, + SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_SET: () => { + return { + default: ['http://localhost:8080'], + } + }, }) }) diff --git a/packages/net-stubbing/lib/server/intercepted-request.ts b/packages/net-stubbing/lib/server/intercepted-request.ts index 9b80816f54b..bf9a6933889 100644 --- a/packages/net-stubbing/lib/server/intercepted-request.ts +++ b/packages/net-stubbing/lib/server/intercepted-request.ts @@ -13,6 +13,7 @@ import type { BackendRoute, NetStubbingState } from './types' import { emit, sendStaticResponse } from './util' import type CyServer from '@packages/server' import type { BackendStaticResponse } from '../internal-types' +import * as errors from '@packages/errors' export class InterceptedRequest { id: string @@ -77,6 +78,14 @@ export class InterceptedRequest { continue } + // if the request is sync and the route has an interceptor (i.e. routeHandler), then skip the intercept + // because the we cannot wait on the before:request event when the sync request is blocking + if (this.req.isSyncRequest && route.hasInterceptor) { + errors.warning('SYNCHRONOUS_XHR_REQUEST_NOT_INTERCEPTED', this.req.proxiedUrl) + + continue + } + const subscriptionsByRoute = { routeId: route.id, immediateStaticResponse: route.staticResponse, diff --git a/packages/net-stubbing/package.json b/packages/net-stubbing/package.json index 8c475e5cf7b..5dec8ed3396 100644 --- a/packages/net-stubbing/package.json +++ b/packages/net-stubbing/package.json @@ -23,6 +23,7 @@ "throttle": "^1.0.3" }, "devDependencies": { + "@packages/errors": "0.0.0-development", "@packages/network": "0.0.0-development", "@packages/proxy": "0.0.0-development", "@packages/server": "0.0.0-development", diff --git a/packages/proxy/lib/http/request-middleware.ts b/packages/proxy/lib/http/request-middleware.ts index 8eeaebae11b..6fff486e343 100644 --- a/packages/proxy/lib/http/request-middleware.ts +++ b/packages/proxy/lib/http/request-middleware.ts @@ -11,6 +11,7 @@ import { doesTopNeedToBeSimulated } from './util/top-simulation' import type { HttpMiddleware } from './' import type { CypressIncomingRequest } from '../types' import { urlMatchesOriginProtectionSpace } from '@packages/network-tools' +import * as errors from '@packages/errors' // do not use a debug namespace in this file - use the per-request `this.debug` instead // available as cypress-verbose:proxy:http @@ -34,11 +35,16 @@ const ExtractCypressMetadataHeaders: RequestMiddleware = function () { this.req.isAUTFrame = !!this.req.headers['x-cypress-is-aut-frame'] this.req.isFromExtraTarget = !!this.req.headers['x-cypress-is-from-extra-target'] + this.req.isSyncRequest = !!this.req.headers['x-cypress-is-sync-request'] if (this.req.headers['x-cypress-is-aut-frame']) { delete this.req.headers['x-cypress-is-aut-frame'] } + if (this.req.headers['x-cypress-is-sync-request']) { + delete this.req.headers['x-cypress-is-sync-request'] + } + span?.setAttributes({ isAUTFrame: this.req.isAUTFrame, isFromExtraTarget: this.req.isFromExtraTarget, @@ -220,6 +226,10 @@ const MaybeAttachCrossOriginCookies: RequestMiddleware = function () { return this.next() } + if (this.req.isSyncRequest) { + errors.warning('SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_APPLIED', this.req.proxiedUrl) + } + // Top needs to be simulated since the AUT is in a cross origin state. Get the "requested with" and credentials and see what cookies need to be attached const currentAUTUrl = this.getAUTUrl() const shouldCookiesBeAttachedToRequest = shouldAttachAndSetCookies(this.req.proxiedUrl, currentAUTUrl, this.req.resourceType, this.req.credentialsLevel, this.req.isAUTFrame) diff --git a/packages/proxy/lib/http/response-middleware.ts b/packages/proxy/lib/http/response-middleware.ts index 930495d1e42..83ab30d975c 100644 --- a/packages/proxy/lib/http/response-middleware.ts +++ b/packages/proxy/lib/http/response-middleware.ts @@ -15,6 +15,7 @@ import { hasServiceWorkerHeader, isVerboseTelemetry as isVerbose } from '.' import { CookiesHelper } from './util/cookies' import * as rewriter from './util/rewriter' import { doesTopNeedToBeSimulated } from './util/top-simulation' +import * as errors from '@packages/errors' import type Debug from 'debug' import type { CookieOptions } from 'express' @@ -719,6 +720,17 @@ const MaybeCopyCookiesFromIncomingRes: ResponseMiddleware = async function () { return this.next() } + // if the request is sync, we cannot wait on the cross:origin:cookies:received + // event since the sync request is blocking. This means that the cross-origin cookies + // may not have been applied. + if (this.req.isSyncRequest) { + errors.warning('SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_SET', this.req.proxiedUrl) + + span?.end() + + return this.next() + } + // we want to set the cookies via automation so they exist in the browser // itself. however, firefox will hang if we try to use the extension // to set cookies on a url that's in-flight, so we send the cookies down to diff --git a/packages/proxy/lib/types.ts b/packages/proxy/lib/types.ts index aecbac28a39..598350d4452 100644 --- a/packages/proxy/lib/types.ts +++ b/packages/proxy/lib/types.ts @@ -28,6 +28,7 @@ export type CypressIncomingRequest = Request & { * Stack-ordered list of `cy.intercept()`s matching this request. */ matchingRoutes?: BackendRoute[] + isSyncRequest: boolean } export type RequestCredentialLevel = 'same-origin' | 'include' | 'omit' | boolean diff --git a/packages/proxy/test/unit/http/request-middleware.spec.ts b/packages/proxy/test/unit/http/request-middleware.spec.ts index de9e7413d9b..88a07445eba 100644 --- a/packages/proxy/test/unit/http/request-middleware.spec.ts +++ b/packages/proxy/test/unit/http/request-middleware.spec.ts @@ -110,6 +110,28 @@ describe('http/request-middleware', () => { expect(ctx.req.isFromExtraTarget).toBe(false) }) }) + + describe('x-cypress-is-sync-request', () => { + it('when it exists, removes header and sets in on the req', async () => { + const ctx = prepareContext({ + 'x-cypress-is-sync-request': 'true', + }) + + await testMiddleware([ExtractCypressMetadataHeaders], ctx) + + expect(ctx.req.headers!['x-cypress-is-sync-request']).toBeUndefined() + expect(ctx.req.isSyncRequest).toBe(true) + }) + + it('when it does not exist, sets in on the req', async () => { + const ctx = prepareContext() + + await testMiddleware([ExtractCypressMetadataHeaders], ctx) + + expect(ctx.req.headers!['x-cypress-is-sync-request']).toBeUndefined() + expect(ctx.req.isSyncRequest).toBe(false) + }) + }) }) describe('CalculateCredentialLevelIfApplicable', () => { diff --git a/packages/runner/injection/cross-origin.js b/packages/runner/injection/cross-origin.js index 297402360c3..036c8fd5829 100644 --- a/packages/runner/injection/cross-origin.js +++ b/packages/runner/injection/cross-origin.js @@ -11,10 +11,10 @@ /* global cypressConfig */ import { createTimers } from './timers' -import { patchDocumentCookie } from './patches/cookies' -import { patchElementIntegrity } from './patches/setAttribute' -import { patchFetch } from './patches/fetch' -import { patchXmlHttpRequest } from './patches/xmlHttpRequest' +import { patchDocumentCookie } from './patches/cross-origin/cookies' +import { patchElementIntegrity } from './patches/cross-origin/setAttribute' +import { patchFetch } from './patches/cross-origin/fetch' +import { patchXmlHttpRequest } from './patches/cross-origin/xmlHttpRequest' const findCypress = () => { for (let index = 0; index < window.parent.frames.length; index++) { diff --git a/packages/runner/injection/main.js b/packages/runner/injection/main.js index ad3eb91de45..d6589401fb1 100644 --- a/packages/runner/injection/main.js +++ b/packages/runner/injection/main.js @@ -8,6 +8,7 @@ */ import { createTimers } from './timers' +import { patchXmlHttpRequest } from './patches/xmlHttpRequest' const Cypress = window.Cypress = parent.Cypress @@ -16,6 +17,8 @@ if (!Cypress) { Cypress in the parent window but it is missing. This should never happen and likely is a bug. Please open an issue.') } +patchXmlHttpRequest(window) + // We wrap timers in the injection code because if we do it in the driver (like // we used to do), any uncaught errors thrown in the timer callbacks would // get picked up by the top frame's 'error' handler instead of the AUT's. diff --git a/packages/runner/injection/patches/cookies.ts b/packages/runner/injection/patches/cross-origin/cookies.ts similarity index 100% rename from packages/runner/injection/patches/cookies.ts rename to packages/runner/injection/patches/cross-origin/cookies.ts diff --git a/packages/runner/injection/patches/fetch.ts b/packages/runner/injection/patches/cross-origin/fetch.ts similarity index 100% rename from packages/runner/injection/patches/fetch.ts rename to packages/runner/injection/patches/cross-origin/fetch.ts diff --git a/packages/runner/injection/patches/setAttribute.ts b/packages/runner/injection/patches/cross-origin/setAttribute.ts similarity index 100% rename from packages/runner/injection/patches/setAttribute.ts rename to packages/runner/injection/patches/cross-origin/setAttribute.ts diff --git a/packages/runner/injection/patches/utils/index.ts b/packages/runner/injection/patches/cross-origin/utils/index.ts similarity index 100% rename from packages/runner/injection/patches/utils/index.ts rename to packages/runner/injection/patches/cross-origin/utils/index.ts diff --git a/packages/runner/injection/patches/cross-origin/xmlHttpRequest.ts b/packages/runner/injection/patches/cross-origin/xmlHttpRequest.ts new file mode 100644 index 00000000000..19cbc88d6cd --- /dev/null +++ b/packages/runner/injection/patches/cross-origin/xmlHttpRequest.ts @@ -0,0 +1,51 @@ +import { captureFullRequestUrl, requestSentWithCredentials } from './utils' + +export const patchXmlHttpRequest = (window: Window) => { + // intercept method calls and add cypress headers to determine cookie applications in the proxy + // for simulated top + + const originalXmlHttpRequestOpen = window.XMLHttpRequest.prototype.open + const originalXmlHttpRequestSend = window.XMLHttpRequest.prototype.send + + window.XMLHttpRequest.prototype.open = function (...args) { + try { + // since the send method does NOT have access to the arguments passed into open or have the request information, + // we need to store a reference here to what we need in the send method + this._url = captureFullRequestUrl(args[1], window) + } finally { + const result = originalXmlHttpRequestOpen.apply(this, args as any) + + if (args.length > 2 && !args[2]) { + this.setRequestHeader('x-cypress-is-sync-request', 'true') + this._isSyncRequest = true + } else { + this._isSyncRequest = false + } + + return result + } + } + + window.XMLHttpRequest.prototype.send = function (...args) { + // if the request is sync, we cannot wait on the requestSentWithCredentials + // function call since the sync request is blocking. + if (this._isSyncRequest) { + return originalXmlHttpRequestSend.apply(this, args) + } + + return (async () => { + 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 "resourceType" in the proxy + 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) + } + })() + } +} diff --git a/packages/runner/injection/patches/xmlHttpRequest.ts b/packages/runner/injection/patches/xmlHttpRequest.ts index f1dfdb69b89..1dae08ce479 100644 --- a/packages/runner/injection/patches/xmlHttpRequest.ts +++ b/packages/runner/injection/patches/xmlHttpRequest.ts @@ -1,34 +1,13 @@ -import { captureFullRequestUrl, requestSentWithCredentials } from './utils' - export const patchXmlHttpRequest = (window: Window) => { - // intercept method calls and add cypress headers to determine cookie applications in the proxy - // for simulated top - const originalXmlHttpRequestOpen = window.XMLHttpRequest.prototype.open - const originalXmlHttpRequestSend = window.XMLHttpRequest.prototype.send window.XMLHttpRequest.prototype.open = function (...args) { - try { - // since the send method does NOT have access to the arguments passed into open or have the request information, - // we need to store a reference here to what we need in the send method - this._url = captureFullRequestUrl(args[1], window) - } finally { - return originalXmlHttpRequestOpen.apply(this, args as any) - } - } + const result = originalXmlHttpRequestOpen.apply(this, 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 "resourceType" in the proxy - 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) + if (args.length > 2 && !args[2]) { + this.setRequestHeader('x-cypress-is-sync-request', 'true') } + + return result } } diff --git a/system-tests/__snapshots__/xhr_spec.js b/system-tests/__snapshots__/xhr_spec.js index 8d6f78d2c3f..a426ab38ed0 100644 --- a/system-tests/__snapshots__/xhr_spec.js +++ b/system-tests/__snapshots__/xhr_spec.js @@ -24,21 +24,28 @@ exports['e2e xhr / passes in global mode'] = ` ✓ does not inject into json's contents from file server even requesting text/html ✓ works prior to visit ✓ can stub a 100kb response +Warning: Cookies may not have been applied to synchronous XHR request: http://www.foobar.com:1919/json. Learn more: https://on.cypress.io/synchronous-xhr-requests + +Warning: Synchronous XHR request was not intercepted: http://www.foobar.com:1919/json. Learn more: https://on.cypress.io/synchronous-xhr-requests + +Warning: Cookies may not have been set for synchronous XHR response: http://www.foobar.com:1919/json. Learn more: https://on.cypress.io/synchronous-xhr-requests + + ✓ displays warnings in the terminal when using sync XHR requests server with 1 visit ✓ response body ✓ request body - aborts - 8 passing + 9 passing 1 pending (Results) ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ - │ Tests: 9 │ - │ Passing: 8 │ + │ Tests: 10 │ + │ Passing: 9 │ │ Failing: 0 │ │ Pending: 1 │ │ Skipped: 0 │ @@ -56,9 +63,9 @@ exports['e2e xhr / passes in global mode'] = ` Spec Tests Passing Failing Pending Skipped ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ - │ ✔ xhr.cy.js XX:XX 9 8 - 1 - │ + │ ✔ xhr.cy.js XX:XX 10 9 - 1 - │ └────────────────────────────────────────────────────────────────────────────────────────────────┘ - ✔ All specs passed! XX:XX 9 8 - 1 - + ✔ All specs passed! XX:XX 10 9 - 1 - ` @@ -89,21 +96,28 @@ exports['e2e xhr / passes through CLI'] = ` ✓ does not inject into json's contents from file server even requesting text/html ✓ works prior to visit ✓ can stub a 100kb response +Warning: Cookies may not have been applied to synchronous XHR request: http://www.foobar.com:1919/json. Learn more: https://on.cypress.io/synchronous-xhr-requests + +Warning: Synchronous XHR request was not intercepted: http://www.foobar.com:1919/json. Learn more: https://on.cypress.io/synchronous-xhr-requests + +Warning: Cookies may not have been set for synchronous XHR response: http://www.foobar.com:1919/json. Learn more: https://on.cypress.io/synchronous-xhr-requests + + ✓ displays warnings in the terminal when using sync XHR requests server with 1 visit ✓ response body ✓ request body - aborts - 8 passing + 9 passing 1 pending (Results) ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ - │ Tests: 9 │ - │ Passing: 8 │ + │ Tests: 10 │ + │ Passing: 9 │ │ Failing: 0 │ │ Pending: 1 │ │ Skipped: 0 │ @@ -121,9 +135,9 @@ exports['e2e xhr / passes through CLI'] = ` Spec Tests Passing Failing Pending Skipped ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ - │ ✔ xhr.cy.js XX:XX 9 8 - 1 - │ + │ ✔ xhr.cy.js XX:XX 10 9 - 1 - │ └────────────────────────────────────────────────────────────────────────────────────────────────┘ - ✔ All specs passed! XX:XX 9 8 - 1 - + ✔ All specs passed! XX:XX 10 9 - 1 - ` diff --git a/system-tests/projects/e2e/cypress/e2e/xhr.cy.js b/system-tests/projects/e2e/cypress/e2e/xhr.cy.js index 17d5d773115..fcab5642f05 100644 --- a/system-tests/projects/e2e/cypress/e2e/xhr.cy.js +++ b/system-tests/projects/e2e/cypress/e2e/xhr.cy.js @@ -122,6 +122,28 @@ describe('xhrs', () => { }) }) + it('displays warnings in the terminal when using sync XHR requests', () => { + // this intercept won't get hit because the request is sync + cy.intercept('http://www.foobar.com:1919/json', (req) => { + req.reply({ + body: '', + }) + }) + + cy.origin('http://www.foobar.com:1919', () => { + cy.visit('/') + cy.window().then((win) => { + const xhr = new win.XMLHttpRequest + + // create a sync request + xhr.open('GET', '/json', false) + xhr.send() + + expect(JSON.parse(xhr.response)).to.deep.eq({ content: 'json' }) + }) + }) + }) + describe('server with 1 visit', () => { beforeEach(() => { cy.visit('/xhr.html') diff --git a/system-tests/test/xhr_spec.js b/system-tests/test/xhr_spec.js index f5b753899ca..80f49711ba3 100644 --- a/system-tests/test/xhr_spec.js +++ b/system-tests/test/xhr_spec.js @@ -15,9 +15,15 @@ const onServer = function (app) { }) }) - return app.post('/html', (req, res) => { + app.post('/html', (req, res) => { return res.json({ content: 'content' }) }) + + app.get('/json', (req, res) => { + res.setHeader('Set-Cookie', 'foo=bar') + + return res.json({ content: 'json' }) + }) } describe('e2e xhr', () => { @@ -26,6 +32,12 @@ describe('e2e xhr', () => { port: 1919, onServer, }, + settings: { + hosts: { + '*.foobar.com': '127.0.0.1', + }, + e2e: {}, + }, }) systemTests.it('passes in global mode', {