From f6a5d1ea2eaace81ef97fc91f17e9945f5ee85d1 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Fri, 26 Mar 2021 18:22:18 +0000 Subject: [PATCH] feat: change cy.intercept override behavior (#14543) --- .../integration/commands/net_stubbing_spec.ts | 826 +++++++++++++----- packages/driver/src/cy/commands/querying.js | 4 +- .../driver/src/cy/net-stubbing/add-command.ts | 48 +- .../cy/net-stubbing/events/after-response.ts | 47 +- .../cy/net-stubbing/events/before-request.ts | 191 ++-- .../src/cy/net-stubbing/events/index.ts | 41 +- .../cy/net-stubbing/events/network-error.ts | 48 + .../src/cy/net-stubbing/events/response.ts | 45 +- .../cy/net-stubbing/static-response-utils.ts | 7 + .../src/cy/net-stubbing/wait-for-route.ts | 2 +- packages/driver/src/cypress/error_messages.js | 32 +- packages/driver/src/cypress/events.ts | 4 +- packages/driver/types/internal-types.d.ts | 2 +- packages/net-stubbing/lib/external-types.ts | 115 ++- packages/net-stubbing/lib/internal-types.ts | 16 +- .../net-stubbing/lib/server/driver-events.ts | 39 +- .../lib/server/intercepted-request.ts | 176 ++-- .../lib/server/middleware/error.ts | 8 +- .../lib/server/middleware/request.ts | 49 +- .../lib/server/middleware/response.ts | 15 +- .../net-stubbing/lib/server/route-matching.ts | 16 +- packages/net-stubbing/lib/server/types.ts | 4 +- packages/net-stubbing/lib/server/util.ts | 12 +- .../test/unit/intercepted-request-spec.ts | 60 ++ .../test/unit/route-matching-spec.ts | 51 ++ .../test/integration/net-stubbing.spec.ts | 10 +- .../cypress/fixtures/errors/intercept_spec.ts | 25 +- .../integration/reporter.errors.spec.js | 4 +- packages/server/lib/socket-base.ts | 1 + scripts/binary/zip.js | 2 +- 30 files changed, 1357 insertions(+), 543 deletions(-) create mode 100644 packages/driver/src/cy/net-stubbing/events/network-error.ts create mode 100644 packages/net-stubbing/test/unit/intercepted-request-spec.ts diff --git a/packages/driver/cypress/integration/commands/net_stubbing_spec.ts b/packages/driver/cypress/integration/commands/net_stubbing_spec.ts index 86c01a4068..2584e2f3db 100644 --- a/packages/driver/cypress/integration/commands/net_stubbing_spec.ts +++ b/packages/driver/cypress/integration/commands/net_stubbing_spec.ts @@ -6,170 +6,214 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function }) context('cy.intercept()', function () { - beforeEach(function () { - // we don't use cy.spy() because it causes an infinite loop with logging events - this.sandbox = sinon.createSandbox() - this.emit = this.sandbox.spy(Cypress, 'emit').withArgs('backend:request', 'net', 'route:added') + context('emits as expected', function () { + beforeEach(function () { + // we don't use cy.spy() because it causes an infinite loop with logging events + this.sandbox = sinon.createSandbox() + this.emit = this.sandbox.spy(Cypress, 'emit').withArgs('backend:request', 'net', 'route:added') - this.testRoute = function (options, handler, expectedEvent, expectedRoute) { - cy.intercept(options, handler).then(function () { - const handlerId = _.findKey(state('routes'), { handler }) - const route = state('routes')[handlerId!] + this.testRoute = function (options, handler, expectedEvent, expectedRoute) { + cy.intercept(options, handler).then(function () { + const routeId = _.findKey(state('routes'), { handler }) + const route = state('routes')[routeId!] - expectedEvent.handlerId = handlerId + expectedEvent.routeId = routeId + expect(this.emit).to.be.calledWith('backend:request', 'net', 'route:added', expectedEvent) + + expect(route.handler).to.deep.eq(expectedRoute.handler) + expect(route.options).to.deep.eq(expectedRoute.options) + }) + } + }) + + afterEach(function () { + this.sandbox.restore() + }) + + it('url, body and stores Route', function () { + const handler = 'bar' + const url = 'http://foo.invalid' + const expectedEvent = { + routeMatcher: { + url: { + type: 'glob', + value: url, + }, + matchUrlAgainstPath: true, + }, + staticResponse: { + body: 'bar', + }, + hasInterceptor: false, + } + + const expectedRoute = { + options: { url, matchUrlAgainstPath: true }, + handler, + } + + this.testRoute(url, handler, expectedEvent, expectedRoute) + }) + + it('url, HTTPController and stores Route', function () { + const handler = () => { + return {} + } + + const url = 'http://foo.invalid' + const expectedEvent = { + routeMatcher: { + url: { + type: 'glob', + value: url, + }, + matchUrlAgainstPath: true, + }, + hasInterceptor: true, + } + + const expectedRoute = { + options: { url, matchUrlAgainstPath: true }, + handler, + } + + this.testRoute(url, handler, expectedEvent, expectedRoute) + }) + + it('regex values stringified and other values copied and stores Route', function () { + const handler = () => { + return {} + } + + const options = { + auth: { + username: 'foo', + password: /.*/, + }, + headers: { + 'Accept-Language': /hylian/i, + 'Content-Encoding': 'corrupted', + }, + hostname: /any.com/, + https: true, + method: 'POST', + path: '/bing?foo', + pathname: '/bazz', + port: [1, 2, 3, 4, 5, 6], + query: { + bar: 'baz', + quuz: /(.*)quux/gi, + }, + url: 'http://foo.invalid', + } + + const expectedEvent = { + routeMatcher: { + auth: { + username: { + type: 'glob', + value: options.auth.username, + }, + password: { + type: 'regex', + value: '/.*/', + }, + }, + headers: { + 'accept-language': { + type: 'regex', + value: '/hylian/i', + }, + 'content-encoding': { + type: 'glob', + value: options.headers['Content-Encoding'], + }, + }, + hostname: { + type: 'regex', + value: '/any.com/', + }, + https: options.https, + method: { + type: 'glob', + value: options.method, + }, + path: { + type: 'glob', + value: options.path, + }, + pathname: { + type: 'glob', + value: options.pathname, + }, + port: options.port, + query: { + bar: { + type: 'glob', + value: options.query.bar, + }, + quuz: { + type: 'regex', + value: '/(.*)quux/gi', + }, + }, + url: { + type: 'glob', + value: options.url, + }, + }, + hasInterceptor: true, + } + + const expectedRoute = { + options, + handler, + } + + this.testRoute(options, handler, expectedEvent, expectedRoute) + }) + + it('mergeRouteMatcher works when supplied', function () { + const url = '/foo' + + const handler = (req) => { + // @ts-ignore + const routeId = _.findKey(state('routes'), { handler }) + const route = state('routes')[routeId!] + + // @ts-ignore + expectedEvent.routeId = routeId expect(this.emit).to.be.calledWith('backend:request', 'net', 'route:added', expectedEvent) expect(route.handler).to.deep.eq(expectedRoute.handler) expect(route.options).to.deep.eq(expectedRoute.options) + + req.reply('a') + } + + const expectedRoute = { + options: { url, matchUrlAgainstPath: true, middleware: true }, + handler, + } + + const expectedEvent = { + routeMatcher: { + url: { + type: 'glob', + value: url, + }, + matchUrlAgainstPath: true, + middleware: true, + }, + hasInterceptor: true, + } + + cy.intercept(url, { middleware: true }, handler).as('get') + .then(() => { + return $.get(url) }) - } - }) - - afterEach(function () { - this.sandbox.restore() - }) - - it('emits with url, body and stores Route', function () { - const handler = 'bar' - const url = 'http://foo.invalid' - const expectedEvent = { - routeMatcher: { - url: { - type: 'glob', - value: url, - }, - matchUrlAgainstPath: true, - }, - staticResponse: { - body: 'bar', - }, - hasInterceptor: false, - } - - const expectedRoute = { - options: { url, matchUrlAgainstPath: true }, - handler, - } - - this.testRoute(url, handler, expectedEvent, expectedRoute) - }) - - it('emits with url, HTTPController and stores Route', function () { - const handler = () => { - return {} - } - - const url = 'http://foo.invalid' - const expectedEvent = { - routeMatcher: { - url: { - type: 'glob', - value: url, - }, - matchUrlAgainstPath: true, - }, - hasInterceptor: true, - } - - const expectedRoute = { - options: { url, matchUrlAgainstPath: true }, - handler, - } - - this.testRoute(url, handler, expectedEvent, expectedRoute) - }) - - it('emits with regex values stringified and other values copied and stores Route', function () { - const handler = () => { - return {} - } - - const options = { - auth: { - username: 'foo', - password: /.*/, - }, - headers: { - 'Accept-Language': /hylian/i, - 'Content-Encoding': 'corrupted', - }, - hostname: /any.com/, - https: true, - method: 'POST', - path: '/bing?foo', - pathname: '/bazz', - port: [1, 2, 3, 4, 5, 6], - query: { - bar: 'baz', - quuz: /(.*)quux/gi, - }, - url: 'http://foo.invalid', - } - - const expectedEvent = { - routeMatcher: { - auth: { - username: { - type: 'glob', - value: options.auth.username, - }, - password: { - type: 'regex', - value: '/.*/', - }, - }, - headers: { - 'accept-language': { - type: 'regex', - value: '/hylian/i', - }, - 'content-encoding': { - type: 'glob', - value: options.headers['Content-Encoding'], - }, - }, - hostname: { - type: 'regex', - value: '/any.com/', - }, - https: options.https, - method: { - type: 'glob', - value: options.method, - }, - path: { - type: 'glob', - value: options.path, - }, - pathname: { - type: 'glob', - value: options.pathname, - }, - port: options.port, - query: { - bar: { - type: 'glob', - value: options.query.bar, - }, - quuz: { - type: 'regex', - value: '/(.*)quux/gi', - }, - }, - url: { - type: 'glob', - value: options.url, - }, - }, - hasInterceptor: true, - } - - const expectedRoute = { - options, - handler, - } - - this.testRoute(options, handler, expectedEvent, expectedRoute) + .wait('@get') + }) }) // https://github.com/cypress-io/cypress/issues/8729 @@ -220,6 +264,167 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function }) }) + context('overrides', function () { + it('chains middleware as expected', function () { + const e: string[] = [] + + cy + .intercept('/dump-headers', { middleware: true }, (req) => { + e.push('mware req handler') + req.on('before:response', (res) => { + e.push('mware before:response') + }) + + req.on('response', (res) => { + e.push('mware response') + }) + + req.on('after:response', (res) => { + e.push('mware after:response') + }) + }) + .intercept('/dump-headers', (req) => { + e.push('normal req handler') + req.reply(() => { + e.push('normal res handler') + }) + }) + .then(() => { + return $.get('/dump-headers') + }) + .wrap(e).should('have.all.members', [ + 'mware req handler', + 'normal req handler', + 'mware before:response', + 'normal res handler', + 'mware response', + 'mware after:response', + ]) + }) + + context('request handler chaining', function () { + it('passes request through in reverse order', function () { + cy.intercept('/dump-method', function (req) { + expect(req.method).to.eq('PATCH') + + req.reply() + }).intercept('/dump-method', function (req) { + expect(req.method).to.eq('POST') + req.method = 'PATCH' + }).intercept('/dump-method', function (req) { + expect(req.method).to.eq('GET') + req.method = 'POST' + }).visit('/dump-method').contains('PATCH') + }) + + it('stops passing request through once req.reply called', function () { + cy.intercept('/dump-method', function (req) { + throw new Error('this should not have been reached') + }).intercept('/dump-method', function (req) { + req.reply() + }).visit('/dump-method').contains('GET') + }) + }) + + context('response handler chaining', function () { + it('passes response through in reverse order', function () { + cy.intercept('/dump-method', function (req) { + req.on('before:response', (res) => { + expect(res.body).to.contain('new body') + }) + }).intercept('/dump-method', function (req) { + req.on('before:response', (res) => { + expect(res.body).to.contain('GET') + res.body = 'new body' + }) + }).visit('/dump-method') + .contains('new body') + }) + + it('stops passing response through once res.send called', function () { + cy.intercept('/dump-method', function (req) { + req.on('before:response', (res) => { + throw new Error('this should not have been reached') + }) + }).intercept('/dump-method', function (req) { + req.on('before:response', (res) => { + res.send() + }) + }).visit('/dump-method').contains('GET') + }) + }) + + it('chains request handlers from bottom-up', function (done) { + cy.intercept('/dump-headers', (req) => { + req.reply((res) => { + expect(res.body).to.include('"x-foo":"bar"') + done() + }) + }) + .intercept('/dump-headers', (req) => req.headers['x-foo'] = 'bar') + .then(() => { + $.get('/dump-headers') + }) + }) + + /** + * https://github.com/cypress-io/cypress/issues/9302 + * https://github.com/cypress-io/cypress/discussions/9339 + * https://github.com/cypress-io/cypress/issues/4460 + */ + it('can override a StaticResponse with another StaticResponse', function () { + cy.intercept('GET', '/items', []) + .intercept('GET', '/items', ['foo', 'bar']) + .then(() => { + return $.getJSON('/items') + }) + .should('deep.eq', ['foo', 'bar']) + }) + + /** + * https://github.com/cypress-io/cypress/discussions/9587 + */ + it('can override an interceptor with another interceptor', function () { + cy.intercept('GET', '**/mydata?**', (req) => { + throw new Error('this should not be called') + }) + .intercept('GET', '/mydata', (req) => { + req.reply({ body: [1, 2, 3, 4, 5] }) + }).as('mydata') + .then(() => { + return $.getJSON('/mydata?abc') + }) + .should('deep.eq', [1, 2, 3, 4, 5]) + .wait('@mydata') + }) + + it('sends a StaticResponse if the newest stub is a StaticResponse', function () { + cy.intercept('/foo', () => { + throw new Error('this should not be called') + }).as('interceptor') + .intercept('/foo', { body: 'something' }).as('staticresponse') + .intercept('/foo').as('spy') + .then(() => { + return $.get('/foo') + }) + .should('deep.eq', 'something') + .wait('@spy') + .wait('@staticresponse') + .get('@interceptor.all').should('have.length', 0) + }) + + it('sends a StaticResponse if a request handler does not supply a response', function () { + cy.intercept('/foo', { body: 'something' }).as('staticresponse') + .intercept('/foo', () => { }).as('interceptor') + .then(() => { + return $.get('/foo') + }) + .should('deep.eq', 'something') + .wait('@interceptor') + .wait('@staticresponse') + }) + }) + context('logging', function () { beforeEach(function () { this.logs = [] @@ -351,6 +556,28 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function cy.intercept() }) + it('cannot merge url with url', function (done) { + cy.on('fail', function (err) { + expect(err.message).to.include('When invoking \`cy.intercept()\` with a \`RouteMatcher\` as the second parameter, \`url\` can only be specified as the first parameter') + + done() + }) + + // @ts-ignore - should fail + cy.intercept('/foo', { url: '/bar' }, () => {}) + }) + + it('cannot pass RouteMatcherOptions in 2nd arg with no handler', function (done) { + cy.on('fail', function (err) { + expect(err.message).to.include('When invoking \`cy.intercept()\` with a \`RouteMatcher\` as the second parameter, a handler (function or \`StaticResponse\`) must be specified as the third parameter.') + + done() + }) + + // sadly this passes typecheck, but the runtime error will prevent this + cy.intercept('/foo', { middleware: true }) + }) + context('with invalid RouteMatcher', function () { it('requires unique header names', function (done) { cy.on('fail', function (err) { @@ -447,17 +674,6 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function }, 'must be a number', ], - [ - 'headers invalid type', - { - headers: { - a: { - 1: 2, - }, - }, - }, - 'must be a map', - ], ].forEach(function ([name, handler, expectedErr]) { it(`${name} fails`, function (done) { cy.on('fail', (err) => { @@ -887,19 +1103,14 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function it('can modify original request body and have it passed to next handler', function (done) { cy.intercept('/post-only', function (req) { - expect(req.body).to.eq('foo-bar-baz') + expect(req.body).to.eq('quuz') + done() + }).intercept('/post-only', function (req) { + expect(req.body).to.eq('quux') req.body = 'quuz' - }).then(function () { - cy.intercept('/post-only', function (req) { - expect(req.body).to.eq('quuz') - req.body = 'quux' - }) - }).then(function () { - cy.intercept('/post-only', function (req) { - expect(req.body).to.eq('quux') - - done() - }) + }).intercept('/post-only', function (req) { + expect(req.body).to.eq('foo-bar-baz') + req.body = 'quux' }).then(function () { $.post('/post-only', 'foo-bar-baz') }) @@ -943,6 +1154,23 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function }) }) + it('can delete a request header', function () { + cy.intercept('/dump-headers', function (req) { + expect(req.headers).to.include({ 'foo': 'bar' }) + delete req.headers['foo'] + }).as('get') + .then(() => { + return $.get({ + url: '/dump-headers', + headers: { + 'foo': 'bar', + }, + }) + }) + .should('not.include', 'foo') + .wait('@get') + }) + it('can modify the request method', function (done) { cy.intercept('/dump-method', function (req) { expect(req.method).to.eq('POST') @@ -1054,6 +1282,74 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function .then(() => testDelay()).wait('@get') }) + context('request events', function () { + context('can end response', () => { + for (const eventName of ['before:response', 'response']) { + it(`in \`${eventName}\``, () => { + const expectBeforeResponse = eventName === 'response' + let beforeResponseCalled = false + + cy.intercept('/foo', (req) => { + req.on('response', (res) => { + throw new Error('response should not be reached') + }) + + req.on('before:response', (res) => { + beforeResponseCalled = true + + if (!expectBeforeResponse) { + throw new Error('before:response should not be reached') + } + }) + }).as('first') + .intercept('/foo', (req) => { + // @ts-ignore + req.on(eventName, (res) => { + res.send({ + statusCode: 200, + fixture: 'valid.json', + }) + }) + }).as('second') + .then(() => { + return $.getJSON('/foo') + }) + .should('include', { 'foo': 1 }) + .wait('@first').wait('@second') + .then(() => { + expect(beforeResponseCalled).to.eq(expectBeforeResponse) + }) + }) + } + }) + + context('errors', function () { + it('when unknown eventName is passed', function (done) { + cy.on('fail', (err) => { + expect(err.message).to.contain('An invalid event name was passed as the first parameter to \`req.on()\`.') + done() + }) + + cy.intercept('/foo', function (req) { + // @ts-ignore + req.on('totally-bad', _.noop) + }).visit('/foo') + }) + + it('when no function is passed', function (done) { + cy.on('fail', (err) => { + expect(err.message).to.contain('\`req.on()\` requires the second parameter to be a function.') + done() + }) + + cy.intercept('/foo', function (req) { + // @ts-ignore + req.on('before:response', false) + }).visit('/foo') + }) + }) + }) + context('body parsing', function () { [ ['application/json', '{"foo":"bar"}'], @@ -1126,19 +1422,19 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function context('matches requests as expected', function () { it('handles querystrings as expected', function () { - cy.intercept({ + cy.intercept('*', 'it worked').as('final') + .intercept({ query: { foo: 'b*r', baz: /quu[x]/, }, - }).as('first') + }).as('third') .intercept({ path: '/abc?foo=bar&baz=qu*x*', }).as('second') .intercept({ pathname: '/abc', - }).as('third') - .intercept('*', 'it worked').as('final') + }).as('first') .then(() => { return $.get('/abc?foo=bar&baz=quux') }) @@ -1283,36 +1579,33 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function }) context('request handler chaining', function () { - it('passes request through in order', function () { + it('passes request through in reverse order', function () { cy.intercept('/dump-method', function (req) { - expect(req.method).to.eq('GET') - req.method = 'POST' + expect(req.method).to.eq('PATCH') + + req.reply() }).intercept('/dump-method', function (req) { expect(req.method).to.eq('POST') req.method = 'PATCH' }).intercept('/dump-method', function (req) { - expect(req.method).to.eq('PATCH') - - req.reply() + expect(req.method).to.eq('GET') + req.method = 'POST' }).visit('/dump-method').contains('PATCH') }) it('stops passing request through once req.reply called', function () { cy.intercept('/dump-method', function (req) { - expect(req.method).to.eq('GET') - req.method = 'POST' + throw new Error('this should not have been reached') }).intercept('/dump-method', function (req) { - expect(req.method).to.eq('POST') - req.reply() - }).visit('/dump-method').contains('POST') + }).visit('/dump-method').contains('GET') }) }) context('errors', function () { it('fails test if req.reply is called twice in req handler', function (done) { cy.on('fail', (err) => { - expect(err.message).to.contain('`req.reply()` was called multiple times in a request handler, but a request can only be replied to once') + expect(err.message).to.contain('`req.reply()` and/or `req.continue()` were called to signal request completion multiple times, but a request can only be completed once') done() }) @@ -1394,13 +1687,11 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function context('correctly determines the content-length of an intercepted request', function () { it('when body is empty', function (done) { cy.intercept('/post-only', function (req) { - req.body = '' - }).then(function () { - cy.intercept('/post-only', function (req) { - expect(req.headers['content-length']).to.eq('0') + expect(req.headers['content-length']).to.eq('0') - done() - }) + done() + }).intercept('/post-only', function (req) { + req.body = '' }) .then(() => { $.post('/post-only', 'foo') @@ -1409,13 +1700,11 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function it('when body contains ascii', function (done) { cy.intercept('/post-only', function (req) { - req.body = 'this is only ascii' - }).then(function () { - cy.intercept('/post-only', function (req) { - expect(req.headers['content-length']).to.eq('18') + expect(req.headers['content-length']).to.eq('18') - done() - }) + done() + }).intercept('/post-only', function (req) { + req.body = 'this is only ascii' }) .then(() => { $.post('/post-only', 'bar') @@ -1424,13 +1713,11 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function it('when body contains unicode', function (done) { cy.intercept('/post-only', function (req) { - req.body = '🙃🤔' - }).then(function () { - cy.intercept('/post-only', function (req) { - expect(req.headers['content-length']).to.eq('8') + expect(req.headers['content-length']).to.eq('8') - done() - }) + done() + }).intercept('/post-only', function (req) { + req.body = '🙃🤔' }) .then(() => { $.post('/post-only', 'baz') @@ -1673,6 +1960,32 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function cy.contains('{"foo":1,"bar":{"baz":"cypress"}}') }) + it('can delete a response header', function () { + cy + .then(() => { + const xhr = $.get('/json-content-type') + + return xhr.then(() => { + return xhr.getAllResponseHeaders() + }) + }) + .should('include', 'content-type: application/json') + .intercept('/json-content-type', function (req) { + req.reply((res) => { + delete res.headers['content-type'] + }) + }).as('get') + .then(() => { + const xhr = $.get('/json-content-type') + + return xhr.then(() => { + return xhr.getAllResponseHeaders() + }) + }) + .should('not.include', 'content-type') + .wait('@get') + }) + context('body parsing', function () { [ 'application/json', @@ -1852,6 +2165,21 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function }) }) + it('res.send({ fixture })', function () { + cy.intercept('/foo*', function (req) { + req.reply((res) => { + res.send({ + statusCode: 200, + fixture: 'valid.json', + }) + }) + }) + .then(() => { + return $.getJSON('/foo') + }) + .should('include', { foo: 1 }) + }) + it('can forceNetworkError', function (done) { cy.intercept('/foo', function (req) { req.reply((res) => { @@ -1900,6 +2228,34 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function }) }) + context('response handler chaining', function () { + it('passes response through in reverse order', function () { + cy.intercept('/dump-method', function (req) { + req.reply((res) => { + expect(res.body).to.contain('new body') + }) + }).intercept('/dump-method', function (req) { + req.reply((res) => { + expect(res.body).to.contain('GET') + res.body = 'new body' + }) + }).visit('/dump-method') + .contains('new body') + }) + + it('stops passing response through once res.send called', function () { + cy.intercept('/dump-method', function (req) { + req.reply((res) => { + throw new Error('this should not have been reached') + }) + }).intercept('/dump-method', function (req) { + req.reply((res) => { + res.send() + }) + }).visit('/dump-method').contains('GET') + }) + }) + context('errors', function () { it('fails test if res.send is called twice in req handler', function (done) { cy.on('fail', (err) => { @@ -1918,7 +2274,7 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function it('fails test if an exception is thrown in res handler', function (done) { cy.on('fail', (err2) => { - expect(err2.message).to.contain('A response callback passed to `req.reply()` threw an error while intercepting a response') + expect(err2.message).to.contain('A response handler threw an error while intercepting a response') .and.contain(err.message) done() @@ -1939,7 +2295,7 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function it('fails test if res.send is called with an invalid StaticResponse', function (done) { cy.on('fail', (err) => { - expect(err.message).to.contain('A response callback passed to `req.reply()` threw an error while intercepting a response') + expect(err.message).to.contain('A response handler threw an error while intercepting a response') .and.contain('must be a number between 100 and 999 (inclusive).') done() @@ -1958,7 +2314,7 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function it('fails test if network error occurs retrieving response and response is intercepted', function (done) { cy.on('fail', (err) => { expect(err.message) - .to.contain('\`req.reply()\` was provided a callback to intercept the upstream response, but a network error occurred while making the request:') + .to.contain('A callback was provided to intercept the upstream response, but a network error occurred while making the request:') .and.contain('Error: connect ECONNREFUSED 127.0.0.1:3333') done() @@ -1998,7 +2354,7 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function it('can timeout in req.reply handler', function (done) { cy.on('fail', (err) => { Cypress.config('defaultCommandTimeout', 5000) - expect(err.message).to.match(/^A response callback passed to `req.reply\(\)` timed out after returning a Promise that took more than the `defaultCommandTimeout` of `50ms` to resolve\./) + expect(err.message).to.match(/^A response handler timed out after returning a Promise that took more than the `defaultCommandTimeout` of `50ms` to resolve\./) done() }) @@ -2017,7 +2373,7 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function responseTimeout: 25, }, function (done) { cy.once('fail', (err) => { - expect(err.message).to.match(/^`req\.reply\(\)` was provided a callback to intercept the upstream response, but the request timed out after the `responseTimeout` of `25ms`\./) + expect(err.message).to.match(/^A callback was provided to intercept the upstream response, but the request timed out after the `responseTimeout` of `25ms`\./) .and.match(/ESOCKETTIMEDOUT|ETIMEDOUT/) done() @@ -2436,6 +2792,38 @@ describe('network stubbing', { retries: { runMode: 2, openMode: 0 } }, function .as('foo') .then(testResponse('something different', done)) }) + + context('when stubbed with fixture', function () { + it('with cy.intercept', function (done) { + cy.intercept('/xml', { fixture: 'null.json' }) + .as('foo') + .then(testResponse('', done)) + }) + + it('with req.reply', function (done) { + cy.intercept('/xml', (req) => req.reply({ fixture: 'null.json' })) + .as('foo') + .then(testResponse('', done)) + }) + + it('with res.send', function (done) { + cy.intercept('/xml', (req) => { + return req.continue((res) => { + return res.send({ + fixture: 'null.json', + headers: { + // since `res.headers['content-type'] is already 'application/xml' from origin, + // we must explicitly set the content-type to JSON here. + // luckily changing content-type like this is not a typical use case + 'content-type': 'application/json', + }, + }) + }) + }) + .as('foo') + .then(testResponse('', done)) + }) + }) }) // @see https://github.com/cypress-io/cypress/issues/9580 diff --git a/packages/driver/src/cy/commands/querying.js b/packages/driver/src/cy/commands/querying.js index 2f27124d92..46d2a4fd7c 100644 --- a/packages/driver/src/cy/commands/querying.js +++ b/packages/driver/src/cy/commands/querying.js @@ -189,7 +189,7 @@ module.exports = (Commands, Cypress, cy, state) => { aliasObj = { alias, - command: state('routes')[request.routeHandlerId].command, + command: state('routes')[request.routeId].command, } } @@ -199,7 +199,7 @@ module.exports = (Commands, Cypress, cy, state) => { if (requests.length) { aliasObj = { alias: toSelect, - command: state('routes')[requests[0].routeHandlerId].command, + command: state('routes')[requests[0].routeId].command, } } } diff --git a/packages/driver/src/cy/net-stubbing/add-command.ts b/packages/driver/src/cy/net-stubbing/add-command.ts index df5cf0e66f..aae6770aa5 100644 --- a/packages/driver/src/cy/net-stubbing/add-command.ts +++ b/packages/driver/src/cy/net-stubbing/add-command.ts @@ -6,6 +6,7 @@ import { RouteMatcher, StaticResponse, HttpRequestInterceptor, + PLAIN_FIELDS, STRING_MATCHER_FIELDS, DICT_STRING_MATCHER_FIELDS, AnnotatedRouteMatcherOptions, @@ -18,6 +19,7 @@ import { validateStaticResponse, getBackendStaticResponse, hasStaticResponseKeys, + hasRouteMatcherKeys, } from './static-response-utils' import { registerEvents } from './events' import $errUtils from '../../cypress/error_utils' @@ -67,9 +69,7 @@ function annotateMatcherOptionsTypes (options: RouteMatcherOptions) { } }) - const noAnnotationRequiredFields: (keyof RouteMatcherOptions)[] = ['https', 'port', 'matchUrlAgainstPath'] - - _.extend(ret, _.pick(options, noAnnotationRequiredFields)) + _.extend(ret, _.pick(options, PLAIN_FIELDS)) return ret } @@ -113,7 +113,7 @@ function validateRouteMatcherOptions (routeMatcher: RouteMatcherOptions): { isVa } } - const booleanProps = ['https', 'matchUrlAgainstPath'] + const booleanProps = ['https', 'matchUrlAgainstPath', 'middleware'] for (const prop of booleanProps) { if (_.has(routeMatcher, prop) && !_.isBoolean(routeMatcher[prop])) { @@ -209,7 +209,7 @@ export function addCommand (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, } function addRoute (matcher: RouteMatcherOptions, handler?: RouteHandler) { - const handlerId = getUniqueId() + const routeId = getUniqueId() const alias = cy.getNextAlias() @@ -245,8 +245,12 @@ export function addCommand (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, routeMatcher.headers = lowercaseFieldNames(routeMatcher.headers) } + if (routeMatcher.middleware && !hasInterceptor) { + return $errUtils.throwErrByPath('net_stubbing.intercept.invalid_middleware_handler', { args: { handler } }) + } + const frame: NetEvent.ToServer.AddRoute = { - handlerId, + routeId, hasInterceptor, routeMatcher, } @@ -255,7 +259,7 @@ export function addCommand (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, frame.staticResponse = getBackendStaticResponse(staticResponse) } - state('routes')[handlerId] = { + state('routes')[routeId] = { log: getNewRouteLog(matcher, !!handler, alias, staticResponse), options: matcher, handler, @@ -265,16 +269,38 @@ export function addCommand (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, } if (alias) { - state('routes')[handlerId].alias = alias + state('routes')[routeId].alias = alias } return emitNetEvent('route:added', frame) } - function intercept (matcher: RouteMatcher, handler?: RouteHandler | StringMatcher, arg2?: RouteHandler) { + function intercept (matcher: RouteMatcher, handler?: RouteHandler | StringMatcher | RouteMatcherOptions, arg2?: RouteHandler) { function getMatcherOptions (): RouteMatcherOptions { + if (_.isString(matcher) && hasRouteMatcherKeys(handler)) { + // url, mergeRouteMatcher, handler + // @ts-ignore + if (handler.url) { + return $errUtils.throwErrByPath('net_stubbing.intercept.no_duplicate_url') + } + + if (!arg2) { + return $errUtils.throwErrByPath('net_stubbing.intercept.handler_required') + } + + const opts = { + url: matcher, + matchUrlAgainstPath: true, + ...handler as RouteMatcherOptions, + } + + handler = arg2 + + return opts + } + if (_.isString(matcher) && $utils.isValidHttpMethod(matcher) && isStringMatcher(handler)) { - // method, url, handler + // method, url, handler? const url = handler as StringMatcher handler = arg2 @@ -287,7 +313,7 @@ export function addCommand (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, } if (isStringMatcher(matcher)) { - // url, handler + // url, handler? return { matchUrlAgainstPath: true, url: matcher, diff --git a/packages/driver/src/cy/net-stubbing/events/after-response.ts b/packages/driver/src/cy/net-stubbing/events/after-response.ts index e5aceca444..0f5fa70dae 100644 --- a/packages/driver/src/cy/net-stubbing/events/after-response.ts +++ b/packages/driver/src/cy/net-stubbing/events/after-response.ts @@ -1,45 +1,17 @@ -import { get } from 'lodash' import { CyHttpMessages } from '@packages/net-stubbing/lib/types' -import { errByPath, makeErrFromObj } from '../../../cypress/error_utils' import { HandlerFn } from '.' +import { parseJsonBody } from './utils' -export const onAfterResponse: HandlerFn = (Cypress, frame, userHandler, { getRequest, getRoute }) => { - const request = getRequest(frame.routeHandlerId, frame.requestId) - - const { data } = frame +export const onAfterResponse: HandlerFn = async (Cypress, frame, userHandler, { getRequest, getRoute }) => { + const request = getRequest(frame.subscription.routeId, frame.requestId) if (!request) { - return frame.data + return null } - if (data.error) { - let err = makeErrFromObj(data.error) - // does this request have a responseHandler that has not run yet? - const isAwaitingResponse = !!request.responseHandler && ['Received', 'Intercepted'].includes(request.state) - const isTimeoutError = data.error.code && ['ESOCKETTIMEDOUT', 'ETIMEDOUT'].includes(data.error.code) - - if (isAwaitingResponse || isTimeoutError) { - const errorName = isTimeoutError ? 'timeout' : 'network_error' - - err = errByPath(`net_stubbing.request_error.${errorName}`, { - innerErr: err, - req: request.request, - route: get(getRoute(frame.routeHandlerId), 'options'), - }) - } - - request.state = 'Errored' - request.error = err - - request.log.error(err) - - if (isAwaitingResponse) { - // the user is implicitly expecting there to be a successful response from the server, so fail the test - // since a network error has occured - throw err - } - - return frame.data + if (request.response && frame.data.finalResBody) { + request.response.body = frame.data.finalResBody + parseJsonBody(request.response) } request.state = 'Complete' @@ -47,5 +19,8 @@ export const onAfterResponse: HandlerFn = (Cypr request.log.fireChangeEvent() request.log.end() - return frame.data + // @ts-ignore + userHandler && await userHandler(request.response!) + + return null } diff --git a/packages/driver/src/cy/net-stubbing/events/before-request.ts b/packages/driver/src/cy/net-stubbing/events/before-request.ts index c2e14285b2..c4f926bf9a 100644 --- a/packages/driver/src/cy/net-stubbing/events/before-request.ts +++ b/packages/driver/src/cy/net-stubbing/events/before-request.ts @@ -13,11 +13,18 @@ import { parseStaticResponseShorthand, } from '../static-response-utils' import $errUtils from '../../../cypress/error_utils' -import { HandlerFn } from '.' +import { HandlerFn, HandlerResult } from '.' import Bluebird from 'bluebird' import { NetEvent } from '@packages/net-stubbing/lib/types' +import Debug from 'debug' -export const onBeforeRequest: HandlerFn = (Cypress, frame, userHandler, { getRoute, emitNetEvent, sendStaticResponse }) => { +const debug = Debug('cypress:driver:net-stubbing:events:before-request') + +type Result = HandlerResult + +const validEvents = ['before:response', 'response', 'after:response'] + +export const onBeforeRequest: HandlerFn = (Cypress, frame, userHandler, { getRoute, getRequest, emitNetEvent, sendStaticResponse }) => { function getRequestLog (route: Route, request: Omit) { return Cypress.log({ name: 'xhr', @@ -46,53 +53,118 @@ export const onBeforeRequest: HandlerFn = (Cypre }) } - const route = getRoute(frame.routeHandlerId) - const { data: req, requestId, routeHandlerId } = frame + const { data: req, requestId, subscription } = frame + const { routeId } = subscription + const route = getRoute(routeId) parseJsonBody(req) - const request: Interception = { - id: requestId, - routeHandlerId, - request: req, - state: 'Received', - requestWaited: false, - responseWaited: false, - subscriptions: [], - on (eventName, handler) { - const subscription: Subscription = { - id: _.uniqueId('Subscription'), - routeHandlerId, - eventName, - await: true, - } + const subscribe = (eventName, handler) => { + const subscription: Subscription = { + id: _.uniqueId('Subscription'), + routeId, + eventName, + await: true, + } - request.subscriptions.push({ - subscription, - handler, - }) + request.subscriptions.push({ + subscription, + handler, + }) - emitNetEvent('subscribe', { requestId, subscription } as NetEvent.ToServer.Subscribe) + debug('created request subscription %o', { eventName, request, subscription, handler }) - return request - }, + emitNetEvent('subscribe', { requestId, subscription } as NetEvent.ToServer.Subscribe) } + const getCanonicalRequest = (): Interception => { + const existingRequest = getRequest(routeId, requestId) + + if (existingRequest) { + existingRequest.request = req + + return existingRequest + } + + return { + id: requestId, + routeId, + request: req, + state: 'Received', + requestWaited: false, + responseWaited: false, + subscriptions: [], + on (eventName, handler) { + if (!validEvents.includes(eventName)) { + return $errUtils.throwErrByPath('net_stubbing.request_handling.unknown_event', { + args: { + validEvents, + eventName, + }, + }) + } + + if (!_.isFunction(handler)) { + return $errUtils.throwErrByPath('net_stubbing.request_handling.event_needs_handler') + } + + subscribe(eventName, handler) + + return request + }, + } + } + + const request: Interception = getCanonicalRequest() + let resolved = false - let replyCalled = false + let handlerCompleted = false const userReq: CyHttpMessages.IncomingHttpRequest = { ...req, - reply (responseHandler, maybeBody?, maybeHeaders?) { + on: request.on, + continue (responseHandler?) { if (resolved) { - return $errUtils.throwErrByPath('net_stubbing.request_handling.reply_called_after_resolved') + return $errUtils.throwErrByPath('net_stubbing.request_handling.completion_called_after_resolved', { args: { cmd: 'continue' } }) } - if (replyCalled) { - return $errUtils.throwErrByPath('net_stubbing.request_handling.multiple_reply_calls') + if (handlerCompleted) { + return $errUtils.throwErrByPath('net_stubbing.request_handling.multiple_completion_calls') } - replyCalled = true + handlerCompleted = true + + if (typeof responseHandler === 'undefined') { + return finish(true) + } + + if (!_.isFunction(responseHandler)) { + return $errUtils.throwErrByPath('net_stubbing.request_handling.req_continue_fn_only') + } + + // allow `req` to be sent outgoing, then pass the response body to `responseHandler` + subscribe('response:callback', responseHandler) + + userReq.responseTimeout = userReq.responseTimeout || Cypress.config('responseTimeout') + + return finish(true) + }, + reply (responseHandler?, maybeBody?, maybeHeaders?) { + if (resolved) { + return $errUtils.throwErrByPath('net_stubbing.request_handling.completion_called_after_resolved', { args: { cmd: 'reply' } }) + } + + if (handlerCompleted) { + return $errUtils.throwErrByPath('net_stubbing.request_handling.multiple_completion_calls') + } + + if (_.isFunction(responseHandler)) { + // backwards-compatibility: before req.continue, req.reply was used to intercept a response + // or to end request handler propagation + return userReq.continue(responseHandler) + } + + handlerCompleted = true const staticResponse = parseStaticResponseShorthand(responseHandler, maybeBody, maybeHeaders) @@ -100,28 +172,16 @@ export const onBeforeRequest: HandlerFn = (Cypre responseHandler = staticResponse } - if (_.isFunction(responseHandler)) { - // allow `req` to be sent outgoing, then pass the response body to `responseHandler` - request.responseHandler = responseHandler - - // signals server to send a http:response:received - request.on('response', responseHandler) - - userReq.responseTimeout = userReq.responseTimeout || Cypress.config('responseTimeout') - - return sendContinueFrame() - } - if (!_.isUndefined(responseHandler)) { // `responseHandler` is a StaticResponse validateStaticResponse('req.reply', responseHandler) sendStaticResponse(requestId, responseHandler) - return finishRequestStage(req) + return updateRequest(req) } - return sendContinueFrame() + return finish(true) }, redirect (location, statusCode = 302) { userReq.reply({ @@ -138,7 +198,7 @@ export const onBeforeRequest: HandlerFn = (Cypre let continueSent = false - function finishRequestStage (req) { + function updateRequest (req) { if (request) { request.request = _.cloneDeep(req) @@ -148,12 +208,12 @@ export const onBeforeRequest: HandlerFn = (Cypre } if (!route) { - return req + return null } - const sendContinueFrame = () => { + const finish = (stopPropagation: boolean) => { if (continueSent) { - throw new Error('sendContinueFrame called twice in handler') + throw new Error('finish called twice in handler') } continueSent = true @@ -161,30 +221,39 @@ export const onBeforeRequest: HandlerFn = (Cypre // copy changeable attributes of userReq to req _.merge(req, _.pick(userReq, SERIALIZABLE_REQ_PROPS)) - finishRequestStage(req) + updateRequest(req) if (_.isObject(req.body)) { req.body = JSON.stringify(req.body) } - resolve(req) + resolve({ + changedData: req, + stopPropagation, + }) } - let resolve: (changedData: CyHttpMessages.IncomingRequest) => void + let resolve: (result: Result) => void - const promise: Promise = new Promise((_resolve) => { + const promise: Promise = new Promise((_resolve) => { resolve = _resolve }) - request.log = getRequestLog(route, request as Omit) + if (!request.log) { + request.log = getRequestLog(route, request as Omit) + } // TODO: this misnomer is a holdover from XHR, should be numRequests route.log.set('numResponses', (route.log.get('numResponses') || 0) + 1) - route.requests[requestId] = request as Interception + + if (!route.requests[requestId]) { + debug('adding request to route', { requestId, routeId }) + route.requests[requestId] = request as Interception + } if (!_.isFunction(userHandler)) { // notification-only - return req + return null } route.hitCount++ @@ -228,10 +297,10 @@ export const onBeforeRequest: HandlerFn = (Cypre delete userReq.alias } - if (!replyCalled) { - // handler function resolved without resolving request, pass on - sendContinueFrame() + if (!handlerCompleted) { + // handler function completed without resolving request, pass on + finish(false) } }) - .return(promise) as any as Bluebird + .return(promise) as any as Bluebird } diff --git a/packages/driver/src/cy/net-stubbing/events/index.ts b/packages/driver/src/cy/net-stubbing/events/index.ts index 561627c36c..6e4cb4cb2b 100644 --- a/packages/driver/src/cy/net-stubbing/events/index.ts +++ b/packages/driver/src/cy/net-stubbing/events/index.ts @@ -2,31 +2,40 @@ import { Route, Interception, StaticResponse, NetEvent } from '../types' import { onBeforeRequest } from './before-request' import { onResponse } from './response' import { onAfterResponse } from './after-response' +import { onNetworkError } from './network-error' import Bluebird from 'bluebird' import { getBackendStaticResponse } from '../static-response-utils' +export type HandlerResult = { + changedData: D + stopPropagation?: boolean +} | null + export type HandlerFn = (Cypress: Cypress.Cypress, frame: NetEvent.ToDriver.Event, userHandler: (data: D) => void | Promise, opts: { - getRequest: (routeHandlerId: string, requestId: string) => Interception | undefined - getRoute: (routeHandlerId: string) => Route | undefined + getRequest: (routeId: string, requestId: string) => Interception | undefined + getRoute: (routeId: string) => Route | undefined emitNetEvent: (eventName: string, frame: any) => Promise sendStaticResponse: (requestId: string, staticResponse: StaticResponse) => void -}) => Promise | D +}) => Promise> | HandlerResult const netEventHandlers: { [eventName: string]: HandlerFn } = { 'before:request': onBeforeRequest, + 'before:response': onResponse, + 'response:callback': onResponse, 'response': onResponse, 'after:response': onAfterResponse, + 'network:error': onNetworkError, } export function registerEvents (Cypress: Cypress.Cypress, cy: Cypress.cy) { const { state } = Cypress - function getRoute (routeHandlerId) { - return state('routes')[routeHandlerId] + function getRoute (routeId) { + return state('routes')[routeId] } - function getRequest (routeHandlerId: string, requestId: string): Interception | undefined { - const route = getRoute(routeHandlerId) + function getRequest (routeId: string, requestId: string): Interception | undefined { + const route = getRoute(routeId) if (route) { return route.requests[requestId] @@ -70,14 +79,14 @@ export function registerEvents (Cypress: Cypress.Cypress, cy: Cypress.cy) { throw new Error(`received unknown net:event in driver: ${eventName}`) } - const emitResolved = (changedData: any) => { + const emitResolved = (result: HandlerResult) => { return emitNetEvent('event:handler:resolved', { eventId: frame.eventId, - changedData, + ...result, }) } - const route = getRoute(frame.routeHandlerId) + const route = getRoute(frame.subscription.routeId) if (!route) { if (frame.subscription.await) { @@ -90,11 +99,11 @@ export function registerEvents (Cypress: Cypress.Cypress, cy: Cypress.cy) { const getUserHandler = () => { if (eventName === 'before:request' && !frame.subscription.id) { - // users do not explicitly subscribe to the first `before:request` event (req handler) + // users can not explicitly subscribe to the first `before:request` event (req handler) return route && route.handler } - const request = getRequest(frame.routeHandlerId, frame.requestId) + const request = getRequest(frame.subscription.routeId, frame.requestId) const subscription = request && request.subscriptions.find(({ subscription }) => { return subscription.id === frame.subscription.id @@ -105,7 +114,11 @@ export function registerEvents (Cypress: Cypress.Cypress, cy: Cypress.cy) { const userHandler = getUserHandler() - const changedData = await handler(Cypress, frame, userHandler, { + if (frame.subscription.await && !userHandler) { + throw new Error('event is waiting for a response, but no user handler was found') + } + + const result = await handler(Cypress, frame, userHandler, { getRoute, getRequest, emitNetEvent, @@ -116,7 +129,7 @@ export function registerEvents (Cypress: Cypress.Cypress, cy: Cypress.cy) { return } - return emitResolved(changedData) + return emitResolved(result) }) .catch(failCurrentTest) }) diff --git a/packages/driver/src/cy/net-stubbing/events/network-error.ts b/packages/driver/src/cy/net-stubbing/events/network-error.ts new file mode 100644 index 0000000000..cae33095ec --- /dev/null +++ b/packages/driver/src/cy/net-stubbing/events/network-error.ts @@ -0,0 +1,48 @@ +import { get } from 'lodash' +import { CyHttpMessages } from '@packages/net-stubbing/lib/types' +import { errByPath, makeErrFromObj } from '../../../cypress/error_utils' +import { HandlerFn } from '.' + +export const onNetworkError: HandlerFn = async (Cypress, frame, userHandler, { getRequest, getRoute }) => { + const request = getRequest(frame.subscription.routeId, frame.requestId) + + const { data } = frame + + if (!request) { + return null + } + + let err = makeErrFromObj(data.error) + // does this request have a user response callback handler? + const hasResponseHandler = !!request.subscriptions.find(({ subscription }) => { + return subscription.eventName === 'response:callback' + }) + const isAwaitingResponse = hasResponseHandler && ['Received', 'Intercepted'].includes(request.state) + const isTimeoutError = data.error.code && ['ESOCKETTIMEDOUT', 'ETIMEDOUT'].includes(data.error.code) + + if (isAwaitingResponse || isTimeoutError) { + const errorName = isTimeoutError ? 'timeout' : 'network_error' + + err = errByPath(`net_stubbing.request_error.${errorName}`, { + innerErr: err, + req: request.request, + route: get(getRoute(frame.subscription.routeId), 'options'), + }) + } + + // @ts-ignore + userHandler && await userHandler(err) + + request.state = 'Errored' + request.error = err + + request.log.error(err) + + if (isAwaitingResponse) { + // the user is implicitly expecting there to be a successful response from the server, so fail the test + // since a network error has occured + throw err + } + + return null +} diff --git a/packages/driver/src/cy/net-stubbing/events/response.ts b/packages/driver/src/cy/net-stubbing/events/response.ts index a79f7621cf..ae33564cb2 100644 --- a/packages/driver/src/cy/net-stubbing/events/response.ts +++ b/packages/driver/src/cy/net-stubbing/events/response.ts @@ -10,17 +10,20 @@ import { STATIC_RESPONSE_KEYS, } from '../static-response-utils' import $errUtils from '../../../cypress/error_utils' -import { HandlerFn } from '.' +import { HandlerFn, HandlerResult } from '.' import Bluebird from 'bluebird' import { parseJsonBody } from './utils' +type Result = HandlerResult + export const onResponse: HandlerFn = async (Cypress, frame, userHandler, { getRoute, getRequest, sendStaticResponse }) => { - const { data: res, requestId, routeHandlerId } = frame - const request = getRequest(frame.routeHandlerId, frame.requestId) + const { data: res, requestId, subscription } = frame + const { routeId } = subscription + const request = getRequest(routeId, frame.requestId) parseJsonBody(res) - let sendCalled = false + let responseSent = false let resolved = false if (request) { @@ -32,7 +35,7 @@ export const onResponse: HandlerFn = async (Cyp // this is notification-only, update the request with the response attributes and end request.response = res - return res + return null } } @@ -45,7 +48,7 @@ export const onResponse: HandlerFn = async (Cyp } if (!request) { - return res + return null } const userRes: CyHttpMessages.IncomingHttpResponse = { @@ -55,12 +58,10 @@ export const onResponse: HandlerFn = async (Cyp return $errUtils.throwErrByPath('net_stubbing.response_handling.send_called_after_resolved', { args: { res } }) } - if (sendCalled) { + if (responseSent) { return $errUtils.throwErrByPath('net_stubbing.response_handling.multiple_send_calls', { args: { res } }) } - sendCalled = true - const shorthand = parseStaticResponseShorthand(staticResponse, maybeBody, maybeHeaders) if (shorthand) { @@ -68,6 +69,7 @@ export const onResponse: HandlerFn = async (Cyp } if (staticResponse) { + responseSent = true validateStaticResponse('res.send', staticResponse) // arguments to res.send() are merged with the existing response @@ -80,7 +82,7 @@ export const onResponse: HandlerFn = async (Cyp return finishResponseStage(_staticResponse) } - return sendContinueFrame() + return sendContinueFrame(true) }, delay (delayMs) { res.delayMs = delayMs @@ -94,7 +96,9 @@ export const onResponse: HandlerFn = async (Cyp }, } - const sendContinueFrame = () => { + const sendContinueFrame = (stopPropagation: boolean) => { + responseSent = true + // copy changeable attributes of userRes to res _.merge(res, _.pick(userRes, SERIALIZABLE_RES_PROPS)) @@ -104,15 +108,18 @@ export const onResponse: HandlerFn = async (Cyp res.body = JSON.stringify(res.body) } - resolve(_.cloneDeep(res)) + resolve({ + changedData: _.cloneDeep(res), + stopPropagation, + }) } const timeout = Cypress.config('defaultCommandTimeout') const curTest = Cypress.state('test') - let resolve: (changedData: CyHttpMessages.IncomingResponse) => void + let resolve: (result: Result) => void - const promise: Promise = new Promise((_resolve) => { + const promise: Promise = new Promise((_resolve) => { resolve = _resolve }) @@ -124,7 +131,7 @@ export const onResponse: HandlerFn = async (Cyp args: { err, req: request.request, - route: _.get(getRoute(routeHandlerId), 'options'), + route: _.get(getRoute(routeId), 'options'), res, }, }) @@ -140,15 +147,15 @@ export const onResponse: HandlerFn = async (Cyp args: { timeout, req: request.request, - route: _.get(getRoute(routeHandlerId), 'options'), + route: _.get(getRoute(routeId), 'options'), res, }, }) }) .then(() => { - if (!sendCalled) { - // user did not call send, send response - userRes.send() + if (!responseSent) { + // user did not send, continue response + sendContinueFrame(false) } }) .finally(() => { diff --git a/packages/driver/src/cy/net-stubbing/static-response-utils.ts b/packages/driver/src/cy/net-stubbing/static-response-utils.ts index 2c4b5fe527..77c24480ba 100644 --- a/packages/driver/src/cy/net-stubbing/static-response-utils.ts +++ b/packages/driver/src/cy/net-stubbing/static-response-utils.ts @@ -4,6 +4,9 @@ import { StaticResponse, BackendStaticResponse, FixtureOpts, + PLAIN_FIELDS, + STRING_MATCHER_FIELDS, + DICT_STRING_MATCHER_FIELDS, } from '@packages/net-stubbing/lib/types' import $errUtils from '../../cypress/error_utils' @@ -109,3 +112,7 @@ export function getBackendStaticResponse (staticResponse: Readonly { + return stripIndent`\ + ${cmd('intercept')}'s \`handler\` argument must be an HttpController function when \`middleware\` is set to \`true\`. + + You passed: ${format(handler)}` + }, invalid_route_matcher: ({ message, matcher }) => { return stripIndent`\ An invalid RouteMatcher was supplied to ${cmd('intercept')}. ${message} You passed: ${format(matcher)}` }, + no_duplicate_url: `When invoking ${cmd('intercept')} with a \`RouteMatcher\` as the second parameter, \`url\` can only be specified as the first parameter.`, + handler_required: `When invoking ${cmd('intercept')} with a \`RouteMatcher\` as the second parameter, a handler (function or \`StaticResponse\`) must be specified as the third parameter. If you intended to stub out a response body by passing an object as the 2nd parameter, pass an object with a \`body\` property containing the desired response body instead.`, }, request_handling: { cb_failed: ({ err, req, route }) => { @@ -964,13 +972,25 @@ module.exports = { Intercepted request: ${format(req)}`, 10) }, - multiple_reply_calls: `\`req.reply()\` was called multiple times in a request handler, but a request can only be replied to once.`, - reply_called_after_resolved: `\`req.reply()\` was called after the request handler finished executing, but \`req.reply()\` can not be called after the request has been passed on.`, + multiple_completion_calls: `\`req.reply()\` and/or \`req.continue()\` were called to signal request completion multiple times, but a request can only be completed once.`, + completion_called_after_resolved: ({ cmd }) => { + return cyStripIndent(`\ + \`req.${cmd}()\` was called after the request handler finished executing, but \`req.${cmd}()\` can not be called after the request has already completed.`, 10) + }, + unknown_event: ({ validEvents, eventName }) => { + return cyStripIndent(`\ + An invalid event name was passed as the first parameter to \`req.on()\`. + + Valid event names are: ${format(validEvents)} + + You passed: ${format(eventName)}`, 10) + }, + event_needs_handler: `\`req.on()\` requires the second parameter to be a function.`, }, request_error: { network_error: ({ innerErr, req, route }) => { return cyStripIndent(`\ - \`req.reply()\` was provided a callback to intercept the upstream response, but a network error occurred while making the request: + A callback was provided to intercept the upstream response, but a network error occurred while making the request: ${normalizedStack(innerErr)} @@ -980,7 +1000,7 @@ module.exports = { }, timeout: ({ innerErr, req, route }) => { return cyStripIndent(`\ - \`req.reply()\` was provided a callback to intercept the upstream response, but the request timed out after the \`responseTimeout\` of \`${req.responseTimeout}ms\`. + A callback was provided to intercept the upstream response, but the request timed out after the \`responseTimeout\` of \`${req.responseTimeout}ms\`. ${normalizedStack(innerErr)} @@ -992,7 +1012,7 @@ module.exports = { response_handling: { cb_failed: ({ err, req, res, route }) => { return cyStripIndent(`\ - A response callback passed to \`req.reply()\` threw an error while intercepting a response: + A response handler threw an error while intercepting a response: ${err.message} @@ -1004,7 +1024,7 @@ module.exports = { }, cb_timeout: ({ timeout, req, res, route }) => { return cyStripIndent(`\ - A response callback passed to \`req.reply()\` timed out after returning a Promise that took more than the \`defaultCommandTimeout\` of \`${timeout}ms\` to resolve. + A response handler timed out after returning a Promise that took more than the \`defaultCommandTimeout\` of \`${timeout}ms\` to resolve. If the response callback is expected to take longer than \`${timeout}ms\`, increase the configured \`defaultCommandTimeout\` value. diff --git a/packages/driver/src/cypress/events.ts b/packages/driver/src/cypress/events.ts index 5be0241eed..13f07e4337 100644 --- a/packages/driver/src/cypress/events.ts +++ b/packages/driver/src/cypress/events.ts @@ -5,7 +5,7 @@ import Bluebird from 'bluebird' const log = Debug('cypress:driver') -const proxyFunctions = ['emit', 'emitThen', 'emitMap'] +const proxyFunctions = ['emit', 'emitThen', 'emitThenSeries', 'emitMap'] const withoutFunctions = (arr) => { return _.reject(arr, _.isFunction) @@ -20,7 +20,7 @@ type CyEvents = { emitThenSeries: (eventName: string, ...args: any[]) => Bluebird } -type Events = EventEmitter2 & CyEvents +export type Events = EventEmitter2 & CyEvents export function extend (obj): Events { const events: EventEmitter2 & Partial = new EventEmitter2() diff --git a/packages/driver/types/internal-types.d.ts b/packages/driver/types/internal-types.d.ts index 2b439a8b1e..008740e6b8 100644 --- a/packages/driver/types/internal-types.d.ts +++ b/packages/driver/types/internal-types.d.ts @@ -23,7 +23,7 @@ declare namespace Cypress { resolveWindowReference: any resolveLocationReference: any routes: { - [routeHandlerId: string]: any + [routeId: string]: any } sinon: sinon.SinonApi utils: CypressUtils diff --git a/packages/net-stubbing/lib/external-types.ts b/packages/net-stubbing/lib/external-types.ts index 2633eed30f..c16afe594e 100644 --- a/packages/net-stubbing/lib/external-types.ts +++ b/packages/net-stubbing/lib/external-types.ts @@ -70,18 +70,35 @@ type Method = | 'unsubscribe' export namespace CyHttpMessages { export interface BaseMessage { - body?: any + /** + * The body of the HTTP message. + * If a JSON Content-Type was used and the body was valid JSON, this will be an object. + * If the body was binary content, this will be a buffer. + */ + body: any + /** + * The headers of the HTTP message. + */ headers: { [key: string]: string } - url: string - method?: Method - httpVersion?: string } export type IncomingResponse = BaseMessage & { + /** + * The HTTP status code of the response. + */ statusCode: number + /** + * The HTTP status message. + */ statusMessage: string - delayMs?: number + /** + * Kilobits per second to send 'body'. + */ throttleKbps?: number + /** + * Milliseconds to delay before the response is sent. + */ + delayMs?: number } export type IncomingHttpResponse = IncomingResponse & { @@ -106,6 +123,22 @@ export namespace CyHttpMessages { } export type IncomingRequest = BaseMessage & { + /** + * Request HTTP method (GET, POST, ...). + */ + method: string + /** + * Request URL. + */ + url: string + /** + * The HTTP version used in the request. Read only. + */ + httpVersion: string + /** + * If provided, the number of milliseconds before an upstream response to this request + * will time out and cause an error. By default, `responseTimeout` from config is used. + */ responseTimeout?: number /** * Set if redirects should be followed when this request is made. By default, requests will @@ -119,11 +152,17 @@ export namespace CyHttpMessages { alias?: string } - export interface IncomingHttpRequest extends IncomingRequest { + export interface IncomingHttpRequest extends IncomingRequest, InterceptionEvents { /** * Destroy the request and respond with a network error. */ destroy(): void + /** + * Send the request outgoing, skipping any other request handlers. + * If a function is passed, the request will be sent outgoing, and the function will be called + * with the response from the upstream server. + */ + continue(interceptor?: HttpResponseInterceptor): void /** * Control the response to this request. * If a function is passed, the request will be sent outgoing, and the function will be called @@ -148,7 +187,11 @@ export namespace CyHttpMessages { } export interface ResponseComplete { - error?: any + finalResBody?: BaseMessage['body'] + } + + export interface NetworkError { + error: any } } @@ -184,18 +227,43 @@ export type NumberMatcher = number | number[] * Metadata for a subscription for an interception event. */ export interface Subscription { + /** + * If not defined, this is a default subscription. + */ id?: string - routeHandlerId: string + routeId: string eventName: string await: boolean + skip?: boolean +} + +interface InterceptionEvents { + /** + * Emitted before `response` and before any `req.continue` handlers. + * Modifications to `res` will be applied to the incoming response. + * If a promise is returned from `cb`, it will be awaited before processing other event handlers. + */ + on(eventName: 'before:response', cb: HttpResponseInterceptor): Interception + /** + * Emitted after `before:response` and after any `req.continue` handlers - before the response is sent to the browser. + * Modifications to `res` will be applied to the incoming response. + * If a promise is returned from `cb`, it will be awaited before processing other event handlers. + */ + on(eventName: 'response', cb: HttpResponseInterceptor): Interception + /** + * Emitted once the response to a request has finished sending to the browser. + * Modifications to `res` have no impact. + * If a promise is returned from `cb`, it will be awaited before processing other event handlers. + */ + on(eventName: 'after:response', cb: (res: CyHttpMessages.IncomingResponse) => void | Promise): Interception } /** * Request/response cycle. */ -export interface Interception { +export interface Interception extends InterceptionEvents { id: string - routeHandlerId: string + routeId: string /* @internal */ log?: any request: CyHttpMessages.IncomingRequest @@ -205,8 +273,6 @@ export interface Interception { */ requestWaited: boolean response?: CyHttpMessages.IncomingResponse - /* @internal */ - responseHandler?: HttpResponseInterceptor /** * The error that occurred during this request. */ @@ -223,12 +289,6 @@ export interface Interception { subscription: Subscription handler: (data: any) => Promise | void }> - /* @internal */ - on(eventName: 'request', cb: () => void): Interception - /* @internal */ - on(eventName: 'before-response', cb: (res: CyHttpMessages.IncomingHttpResponse) => void): Interception - /* @internal */ - on(eventName: 'response', cb: (res: CyHttpMessages.IncomingHttpResponse) => void): Interception } export type InterceptionState = @@ -286,6 +346,12 @@ export interface RouteMatcherOptionsGeneric { * @default '*' */ method?: S + /** + * If `true`, this will pass the request on to the next `RouteMatcher` after the request handler completes. + * Can only be used with a dynamic request handler. + * @default false + */ + middleware?: boolean /** * Match on request path after the hostname, including query params. */ @@ -407,7 +473,7 @@ declare global { * }) * @example * cy.intercept('https://localhost:7777/some-response', (req) => { - * req.reply(res => { + * req.continue(res => { * res.body = 'some new body' * }) * }) @@ -421,6 +487,17 @@ declare global { * cy.intercept('GET', 'http://foo.com/fruits', ['apple', 'banana', 'cherry']) */ intercept(method: Method, url: RouteMatcher, response?: RouteHandler): Chainable + /** + * Use `cy.intercept()` to stub and intercept HTTP requests and responses. + * + * @see https://on.cypress.io/intercept + * + * @example + * cy.intercept('/fruits', { middleware: true }, (req) => { ... }) + * + * @param mergeRouteMatcher Additional route matcher options to merge with `url`. Typically used for middleware. + */ + intercept(url: string, mergeRouteMatcher: Omit, response: RouteHandler): Chainable /** * Wait for a specific request to complete. * diff --git a/packages/net-stubbing/lib/internal-types.ts b/packages/net-stubbing/lib/internal-types.ts index 30aeff4c27..f8fe2f9cfc 100644 --- a/packages/net-stubbing/lib/internal-types.ts +++ b/packages/net-stubbing/lib/internal-types.ts @@ -3,6 +3,7 @@ import { RouteMatcherOptionsGeneric, GenericStaticResponse, Subscription, + CyHttpMessages, } from './external-types' export type FixtureOpts = { @@ -30,7 +31,9 @@ export const SERIALIZABLE_RES_PROPS = _.concat( 'throttleKbps', ) -export const DICT_STRING_MATCHER_FIELDS = ['headers', 'query'] +export const PLAIN_FIELDS: (keyof RouteMatcherOptionsGeneric)[] = ['https', 'port', 'matchUrlAgainstPath', 'middleware'] + +export const DICT_STRING_MATCHER_FIELDS: (keyof RouteMatcherOptionsGeneric)[] = ['headers', 'query'] export const STRING_MATCHER_FIELDS = ['auth.username', 'auth.password', 'hostname', 'method', 'path', 'pathname', 'url'] @@ -50,7 +53,6 @@ export type AnnotatedRouteMatcherOptions = RouteMatcherOptionsGeneric {} + + export interface Response extends Event {} } export namespace ToServer { @@ -66,7 +72,7 @@ export declare namespace NetEvent { routeMatcher: AnnotatedRouteMatcherOptions staticResponse?: BackendStaticResponse hasInterceptor: boolean - handlerId?: string + routeId: string } export interface Subscribe { @@ -77,6 +83,10 @@ export declare namespace NetEvent { export interface EventHandlerResolved { eventId: string changedData: any + /** + * If `true`, no further handlers for this event will be called. + */ + stopPropagation: boolean } export interface SendStaticResponse { diff --git a/packages/net-stubbing/lib/server/driver-events.ts b/packages/net-stubbing/lib/server/driver-events.ts index b1e3e28473..86abcc6761 100644 --- a/packages/net-stubbing/lib/server/driver-events.ts +++ b/packages/net-stubbing/lib/server/driver-events.ts @@ -6,6 +6,7 @@ import { BackendRoute, } from './types' import { + PLAIN_FIELDS, AnnotatedRouteMatcherOptions, RouteMatcherOptions, NetEvent, @@ -15,6 +16,8 @@ import { sendStaticResponse as _sendStaticResponse, setResponseFromFixture, } from './util' +import { InterceptedRequest } from './intercepted-request' +import CyServer from '@packages/server' const debug = Debug('cypress:net-stubbing:server:driver-events') @@ -27,9 +30,11 @@ async function onRouteAdded (state: NetStubbingState, getFixture: GetFixtureFn, } const route: BackendRoute = { + id: options.routeId, + hasInterceptor: options.hasInterceptor, + staticResponse: options.staticResponse, routeMatcher, getFixture, - ..._.omit(options, 'routeMatcher'), // skip the user's un-annotated routeMatcher } state.routes.push(route) @@ -48,24 +53,7 @@ function subscribe (state: NetStubbingState, options: NetEvent.ToServer.Subscrib return } - // filter out any stub subscriptions that are no longer needed - _.remove(request.subscriptions, ({ eventName, routeHandlerId, id }) => { - return eventName === options.subscription.eventName && routeHandlerId === options.subscription.routeHandlerId && !id - }) - - request.subscriptions.push(options.subscription) -} - -function eventHandlerResolved (state: NetStubbingState, options: NetEvent.ToServer.EventHandlerResolved) { - const pendingEventHandler = state.pendingEventHandlers[options.eventId] - - if (!pendingEventHandler) { - return - } - - delete state.pendingEventHandlers[options.eventId] - - pendingEventHandler(options.changedData) + request.addSubscription(options.subscription) } async function sendStaticResponse (state: NetStubbingState, getFixture: GetFixtureFn, options: NetEvent.ToServer.SendStaticResponse) { @@ -75,6 +63,12 @@ async function sendStaticResponse (state: NetStubbingState, getFixture: GetFixtu return } + if (options.staticResponse.fixture && ['before:response', 'response:callback', 'response'].includes(request.lastEvent!)) { + // if we're already in a response phase, it's possible that the fixture body will never be sent to the browser + // so include the fixture body in `after:response` + request.includeBodyInAfterResponse = true + } + await setResponseFromFixture(getFixture, options.staticResponse) _sendStaticResponse(request, options.staticResponse) @@ -105,9 +99,7 @@ export function _restoreMatcherOptionsTypes (options: AnnotatedRouteMatcherOptio _.set(ret, field, value) }) - const noAnnotationRequiredFields: (keyof AnnotatedRouteMatcherOptions)[] = ['https', 'port', 'matchUrlAgainstPath'] - - _.extend(ret, _.pick(options, noAnnotationRequiredFields)) + _.extend(ret, _.pick(options, PLAIN_FIELDS)) return ret } @@ -115,6 +107,7 @@ export function _restoreMatcherOptionsTypes (options: AnnotatedRouteMatcherOptio type OnNetEventOpts = { eventName: string state: NetStubbingState + socket: CyServer.Socket getFixture: GetFixtureFn args: any[] frame: NetEvent.ToServer.AddRoute | NetEvent.ToServer.EventHandlerResolved | NetEvent.ToServer.Subscribe | NetEvent.ToServer.SendStaticResponse @@ -131,7 +124,7 @@ export async function onNetEvent (opts: OnNetEventOpts): Promise { case 'subscribe': return subscribe(state, frame) case 'event:handler:resolved': - return eventHandlerResolved(state, frame) + return InterceptedRequest.resolveEventHandler(state, frame) case 'send:static:response': return sendStaticResponse(state, getFixture, frame) default: diff --git a/packages/net-stubbing/lib/server/intercepted-request.ts b/packages/net-stubbing/lib/server/intercepted-request.ts index 21d3af756e..10f9aabb49 100644 --- a/packages/net-stubbing/lib/server/intercepted-request.ts +++ b/packages/net-stubbing/lib/server/intercepted-request.ts @@ -9,12 +9,20 @@ import { NetEvent, Subscription, } from '../types' -import { NetStubbingState } from './types' -import { emit } from './util' +import { BackendRoute, NetStubbingState } from './types' +import { emit, sendStaticResponse } from './util' import CyServer from '@packages/server' +import { BackendStaticResponse } from '../internal-types' export class InterceptedRequest { id: string + subscriptionsByRoute: Array<{ + routeId: string + immediateStaticResponse?: BackendStaticResponse + subscriptions: Subscription[] + }> = [] + includeBodyInAfterResponse: boolean = false + lastEvent?: string onError: (err: Error) => void /** * A callback that can be used to make the request go outbound through the rest of the request proxy steps. @@ -31,91 +39,149 @@ export class InterceptedRequest { req: CypressIncomingRequest res: CypressOutgoingResponse incomingRes?: IncomingMessage - subscriptions: Subscription[] + matchingRoutes: BackendRoute[] state: NetStubbingState socket: CyServer.Socket - constructor (opts: Pick) { + constructor (opts: Pick) { this.id = _.uniqueId('interceptedRequest') this.req = opts.req this.res = opts.res this.continueRequest = opts.continueRequest this.onError = opts.onError this.onResponse = opts.onResponse - this.subscriptions = opts.subscriptions + this.matchingRoutes = opts.matchingRoutes this.state = opts.state this.socket = opts.socket + + this.addDefaultSubscriptions() + } + + private addDefaultSubscriptions () { + if (this.subscriptionsByRoute.length) { + throw new Error('cannot add default subscriptions to non-empty array') + } + + for (const route of this.matchingRoutes) { + const subscriptionsByRoute = { + routeId: route.id, + immediateStaticResponse: route.staticResponse, + subscriptions: [{ + eventName: 'before:request', + await: !!route.hasInterceptor, + routeId: route.id, + }, + ...(['response:callback', 'after:response', 'network:error'].map((eventName) => { + // notification-only default event + return { eventName, await: false, routeId: route.id } + }))], + } + + this.subscriptionsByRoute.push(subscriptionsByRoute) + } + } + + static resolveEventHandler (state: NetStubbingState, options: { eventId: string, changedData: any, stopPropagation: boolean }) { + const pendingEventHandler = state.pendingEventHandlers[options.eventId] + + if (!pendingEventHandler) { + return + } + + delete state.pendingEventHandlers[options.eventId] + + pendingEventHandler(options) + } + + addSubscription (subscription: Subscription) { + const subscriptionsByRoute = _.find(this.subscriptionsByRoute, { routeId: subscription.routeId }) + + if (!subscriptionsByRoute) { + throw new Error('expected to find existing subscriptions for route, but request did not originally match route') + } + + // filter out any defaultSub subscriptions that are no longer needed + const defaultSub = _.find(subscriptionsByRoute.subscriptions, ({ eventName, routeId, id, skip }) => { + return eventName === subscription.eventName && routeId === subscription.routeId && !id && !skip + }) + + defaultSub && (defaultSub.skip = true) + + subscriptionsByRoute.subscriptions.push(subscription) } /* - * Run all subscriptions for an event in order, awaiting responses if applicable. + * Run all subscriptions for an event, awaiting responses if applicable. + * Subscriptions are run in order, first sorted by matched route order, then by subscription definition order. * Resolves with the updated object, or the original object if no changes have been made. */ async handleSubscriptions ({ eventName, data, mergeChanges }: { - eventName: string + eventName: string | string[] data: D /* - * Given a `before` snapshot and an `after` snapshot, calculate the modified object. + * Given a `before` snapshot and an `after` snapshot, add the changes from `after` to `before`. */ - mergeChanges: (before: D, after: D) => D + mergeChanges: (before: D, after: D) => void }): Promise { - const handleSubscription = async (subscription: Subscription) => { - const eventId = _.uniqueId('event') - const eventFrame: NetEvent.ToDriver.Event = { - eventId, - subscription, - requestId: this.id, - routeHandlerId: subscription.routeHandlerId, - data, - } + const eventNames = Array.isArray(eventName) ? eventName : [eventName] + let stopPropagationNow - const _emit = () => emit(this.socket, eventName, eventFrame) + outerLoop: for (const eventName of eventNames) { + this.lastEvent = eventName - if (!subscription.await) { - _emit() - - return data - } - - const p = new Promise((resolve) => { - this.state.pendingEventHandlers[eventId] = resolve - }) - - _emit() - - const changedData = await p - - return mergeChanges(data, changedData as any) - } - - let lastI = -1 - - const getNextSubscription = () => { - return _.find(this.subscriptions, (v, i) => { - if (i > lastI && v.eventName === eventName) { - lastI = i - - return v + const handleSubscription = async (subscription: Subscription): Promise => { + if (subscription.skip || subscription.eventName !== eventName) { + return } - return - }) as Subscription | undefined - } + const eventId = _.uniqueId('event') + const eventFrame: NetEvent.ToDriver.Event = { + eventId, + subscription, + requestId: this.id, + data, + } - const run = async () => { - const subscription = getNextSubscription() + const _emit = () => emit(this.socket, eventName, eventFrame) - if (!subscription) { - return + if (!subscription.await) { + _emit() + + return + } + + const p = new Promise((resolve) => { + this.state.pendingEventHandlers[eventId] = resolve + }) + + _emit() + + const { changedData, stopPropagation } = await p as any + + stopPropagationNow = stopPropagation + + if (changedData) { + mergeChanges(data, changedData as any) + } } - data = await handleSubscription(subscription) + for (const { subscriptions, immediateStaticResponse } of this.subscriptionsByRoute) { + for (const subscription of subscriptions) { + await handleSubscription(subscription) - await run() + if (stopPropagationNow) { + break outerLoop + } + } + + if (eventName === 'before:request' && immediateStaticResponse) { + sendStaticResponse(this, immediateStaticResponse) + + return data + } + } } - await run() - return data } } diff --git a/packages/net-stubbing/lib/server/middleware/error.ts b/packages/net-stubbing/lib/server/middleware/error.ts index eb6aa140fd..bc48512223 100644 --- a/packages/net-stubbing/lib/server/middleware/error.ts +++ b/packages/net-stubbing/lib/server/middleware/error.ts @@ -7,7 +7,7 @@ import errors from '@packages/server/lib/errors' const debug = Debug('cypress:net-stubbing:server:intercept-error') -export const InterceptError: ErrorMiddleware = function () { +export const InterceptError: ErrorMiddleware = async function () { const request = this.netStubbingState.requests[this.req.requestId] if (!request) { @@ -19,12 +19,12 @@ export const InterceptError: ErrorMiddleware = function () { request.continueResponse = this.next - request.handleSubscriptions({ - eventName: 'after:response', + await request.handleSubscriptions({ + eventName: 'network:error', data: { error: errors.clone(this.error), }, - mergeChanges: _.identity, + mergeChanges: _.noop, }) this.next() diff --git a/packages/net-stubbing/lib/server/middleware/request.ts b/packages/net-stubbing/lib/server/middleware/request.ts index 8173621615..70923a4b3d 100644 --- a/packages/net-stubbing/lib/server/middleware/request.ts +++ b/packages/net-stubbing/lib/server/middleware/request.ts @@ -15,9 +15,10 @@ import { getRouteForRequest, matchesRoutePreflight } from '../route-matching' import { sendStaticResponse, setDefaultHeaders, + mergeDeletedHeaders, } from '../util' -import { Subscription } from '../../external-types' import { InterceptedRequest } from '../intercepted-request' +import { BackendRoute } from '../types' const debug = Debug('cypress:net-stubbing:server:intercept-request') @@ -39,41 +40,23 @@ export const InterceptRequest: RequestMiddleware = async function () { }) } - let lastRoute - const subscriptions: Subscription[] = [] + const matchingRoutes: BackendRoute[] = [] - const addDefaultSubscriptions = (prevRoute?) => { + const populateMatchingRoutes = (prevRoute?) => { const route = getRouteForRequest(this.netStubbingState.routes, this.req, prevRoute) if (!route) { return } - Array.prototype.push.apply(subscriptions, [{ - eventName: 'before:request', - // req.reply callback? - await: !!route.hasInterceptor, - routeHandlerId: route.handlerId, - }, { - eventName: 'response', - // notification-only - await: false, - routeHandlerId: route.handlerId, - }, { - eventName: 'after:response', - // notification-only - await: false, - routeHandlerId: route.handlerId, - }]) + matchingRoutes.push(route) - lastRoute = route - - addDefaultSubscriptions(route) + populateMatchingRoutes(route) } - addDefaultSubscriptions() + populateMatchingRoutes() - if (!subscriptions.length) { + if (!matchingRoutes.length) { // not intercepted, carry on normally... return this.next() } @@ -89,7 +72,7 @@ export const InterceptRequest: RequestMiddleware = async function () { res: this.res, socket: this.socket, state: this.netStubbingState, - subscriptions, + matchingRoutes, }) debug('intercepting request %o', { requestId: request.id, req: _.pick(this.req, 'url') }) @@ -106,8 +89,10 @@ export const InterceptRequest: RequestMiddleware = async function () { request.res.once('finish', async () => { request.handleSubscriptions({ eventName: 'after:response', - data: {}, - mergeChanges: _.identity, + data: request.includeBodyInAfterResponse ? { + finalResBody: request.res.body!, + } : {}, + mergeChanges: _.noop, }) debug('request/response finished, cleaning up %o', { requestId: request.id }) @@ -148,7 +133,9 @@ export const InterceptRequest: RequestMiddleware = async function () { // resolve and propagate any changes to the URL request.req.proxiedUrl = after.url = url.resolve(request.req.proxiedUrl, after.url) - return _.merge(before, _.pick(after, SERIALIZABLE_REQ_PROPS)) + _.merge(before, _.pick(after, SERIALIZABLE_REQ_PROPS)) + + mergeDeletedHeaders(before, after) } const modifiedReq = await request.handleSubscriptions({ @@ -157,10 +144,6 @@ export const InterceptRequest: RequestMiddleware = async function () { mergeChanges, }) - if (lastRoute.staticResponse) { - return sendStaticResponse(request, lastRoute.staticResponse) - } - mergeChanges(req, modifiedReq) // @ts-ignore mergeChanges(request.req, req) diff --git a/packages/net-stubbing/lib/server/middleware/response.ts b/packages/net-stubbing/lib/server/middleware/response.ts index d9512bd078..df3fb267d1 100644 --- a/packages/net-stubbing/lib/server/middleware/response.ts +++ b/packages/net-stubbing/lib/server/middleware/response.ts @@ -13,6 +13,7 @@ import { } from '../../types' import { getBodyStream, + mergeDeletedHeaders, } from '../util' const debug = Debug('cypress:net-stubbing:server:intercept-response') @@ -65,15 +66,19 @@ export const InterceptResponse: ResponseMiddleware = async function () { throw new Error('res.body must be a string or a Buffer') } + const mergeChanges = (before: CyHttpMessages.IncomingResponse, after: CyHttpMessages.IncomingResponse) => { + _.merge(before, _.pick(after, SERIALIZABLE_RES_PROPS)) + + mergeDeletedHeaders(before, after) + } + const modifiedRes = await request.handleSubscriptions({ - eventName: 'response', + eventName: ['before:response', 'response:callback', 'response'], data: res, - mergeChanges: (before, after) => { - return _.merge(before, _.pick(after, SERIALIZABLE_RES_PROPS)) - }, + mergeChanges, }) - _.merge(request.res, modifiedRes) + mergeChanges(request.res as any, modifiedRes) const bodyStream = getBodyStream(modifiedRes.body, _.pick(modifiedRes, ['throttleKbps', 'delayMs']) as any) diff --git a/packages/net-stubbing/lib/server/route-matching.ts b/packages/net-stubbing/lib/server/route-matching.ts index 5cf12ef1f5..b9923e08fe 100644 --- a/packages/net-stubbing/lib/server/route-matching.ts +++ b/packages/net-stubbing/lib/server/route-matching.ts @@ -128,11 +128,19 @@ export function _getMatchableForRequest (req: CypressIncomingRequest) { * Try to match a `BackendRoute` to a request, optionally starting after `prevRoute`. */ export function getRouteForRequest (routes: BackendRoute[], req: CypressIncomingRequest, prevRoute?: BackendRoute) { - const possibleRoutes = prevRoute ? routes.slice(_.findIndex(routes, prevRoute) + 1) : routes + const [middleware, handlers] = _.partition(routes, (route) => route.routeMatcher.middleware === true) + // First, match the oldest matching route handler with `middleware: true`. + // Then, match the newest matching route handler. + const orderedRoutes = middleware.concat(handlers.reverse()) + const possibleRoutes = prevRoute ? orderedRoutes.slice(_.findIndex(orderedRoutes, prevRoute) + 1) : orderedRoutes - return _.find(possibleRoutes, (route) => { - return _doesRouteMatch(route.routeMatcher, req) - }) + for (const route of possibleRoutes) { + if (_doesRouteMatch(route.routeMatcher, req)) { + return route + } + } + + return } function isPreflightRequest (req: CypressIncomingRequest) { diff --git a/packages/net-stubbing/lib/server/types.ts b/packages/net-stubbing/lib/server/types.ts index c49f5ab2a3..97f0b64327 100644 --- a/packages/net-stubbing/lib/server/types.ts +++ b/packages/net-stubbing/lib/server/types.ts @@ -10,7 +10,7 @@ export type GetFixtureFn = (path: string, opts?: { encoding?: string | null }) = export interface BackendRoute { routeMatcher: RouteMatcherOptions - handlerId?: string + id: string hasInterceptor: boolean staticResponse?: BackendStaticResponse getFixture: GetFixtureFn @@ -18,7 +18,7 @@ export interface BackendRoute { export interface NetStubbingState { pendingEventHandlers: { - [eventId: string]: Function + [eventId: string]: (opts: { changedData: any, stopPropagation: boolean }) => void } requests: { [requestId: string]: InterceptedRequest diff --git a/packages/net-stubbing/lib/server/util.ts b/packages/net-stubbing/lib/server/util.ts index db153cb1d1..58d36bda42 100644 --- a/packages/net-stubbing/lib/server/util.ts +++ b/packages/net-stubbing/lib/server/util.ts @@ -19,6 +19,7 @@ import { InterceptedRequest } from './intercepted-request' // TODO: move this into net-stubbing once cy.route is removed import { parseContentType } from '@packages/server/lib/controllers/xhrs' +import { CyHttpMessages } from '../external-types' const debug = Debug('cypress:net-stubbing:server:util') @@ -146,7 +147,7 @@ export async function setResponseFromFixture (getFixtureFn: GetFixtureFn, static * @param backendRequest BackendRequest object. * @param staticResponse BackendStaticResponse object. */ -export function sendStaticResponse (backendRequest: Pick, staticResponse: BackendStaticResponse) { +export function sendStaticResponse (backendRequest: Pick, staticResponse: BackendStaticResponse) { const { onError, onResponse } = backendRequest if (staticResponse.forceNetworkError) { @@ -158,7 +159,7 @@ export function sendStaticResponse (backendRequest: Pick { + context('handleSubscriptions', () => { + it('handles subscriptions as expected', async () => { + const socket = { + toDriver: sinon.stub(), + } + const state = NetStubbingState() + const interceptedRequest = new InterceptedRequest({ + state, + socket, + matchingRoutes: [ + // @ts-ignore + { + id: '1', + hasInterceptor: true, + }, + // @ts-ignore + { + id: '2', + hasInterceptor: true, + }, + ], + }) + + interceptedRequest.addSubscription({ + routeId: '1', + eventName: 'before:response', + await: true, + }) + + const data = { foo: 'bar' } + + socket.toDriver.callsFake((eventName, subEventName, frame) => { + expect(eventName).to.eq('net:event') + expect(subEventName).to.eq('before:request') + expect(frame).to.deep.include({ + subscription: { + eventName: 'before:request', + await: true, + routeId: frame.subscription.routeId, + }, + }) + + state.pendingEventHandlers[frame.eventId](frame.data) + }) + + await interceptedRequest.handleSubscriptions({ + eventName: 'before:request', + data, + mergeChanges: _.merge, + }) + }) + }) +}) diff --git a/packages/net-stubbing/test/unit/route-matching-spec.ts b/packages/net-stubbing/test/unit/route-matching-spec.ts index 810573ab39..145eb04ac5 100644 --- a/packages/net-stubbing/test/unit/route-matching-spec.ts +++ b/packages/net-stubbing/test/unit/route-matching-spec.ts @@ -1,10 +1,12 @@ import { _doesRouteMatch, _getMatchableForRequest, + getRouteForRequest, } from '../../lib/server/route-matching' import { RouteMatcherOptions } from '../../lib/types' import { expect } from 'chai' import { CypressIncomingRequest } from '@packages/proxy' +import { BackendRoute } from '../../lib/server/types' describe('intercept-request', function () { context('._getMatchableForRequest', function () { @@ -185,4 +187,53 @@ describe('intercept-request', function () { }) }) }) + + context('.getRouteForRequest', function () { + it('matches middleware, then handlers', function () { + const routes: Partial[] = [ + { + id: '1', + routeMatcher: { + middleware: true, + pathname: '/foo', + }, + }, + { + id: '2', + routeMatcher: { + pathname: '/foo', + }, + }, + { + id: '3', + routeMatcher: { + middleware: true, + pathname: '/foo', + }, + }, + { + id: '4', + routeMatcher: { + pathname: '/foo', + }, + }, + ] + + const req: Partial = { + method: 'GET', + headers: {}, + proxiedUrl: 'http://bar.baz/foo?_', + } + + let prevRoute: BackendRoute + let e: string[] = [] + + // @ts-ignore + while ((prevRoute = getRouteForRequest(routes, req, prevRoute))) { + e.push(prevRoute.id) + } + + expect(e).to.deep.eq(['1', '3', '4', '2']) + }) + }) }) diff --git a/packages/proxy/test/integration/net-stubbing.spec.ts b/packages/proxy/test/integration/net-stubbing.spec.ts index bc2fba14e6..73b5d77e44 100644 --- a/packages/proxy/test/integration/net-stubbing.spec.ts +++ b/packages/proxy/test/integration/net-stubbing.spec.ts @@ -83,7 +83,7 @@ context('network stubbing', () => { it('adds CORS headers to static stubs', () => { netStubbingState.routes.push({ - handlerId: '1', + id: '1', routeMatcher: { url: '*', }, @@ -108,7 +108,7 @@ context('network stubbing', () => { it('does not override CORS headers', () => { netStubbingState.routes.push({ - handlerId: '1', + id: '1', routeMatcher: { url: '*', }, @@ -133,7 +133,7 @@ context('network stubbing', () => { it('uses Origin to set CORS header', () => { netStubbingState.routes.push({ - handlerId: '1', + id: '1', routeMatcher: { url: '*', }, @@ -156,7 +156,7 @@ context('network stubbing', () => { it('adds CORS headers to dynamically intercepted requests', () => { netStubbingState.routes.push({ - handlerId: '1', + id: '1', routeMatcher: { url: '*', }, @@ -223,7 +223,7 @@ context('network stubbing', () => { .attach('file', png) netStubbingState.routes.push({ - handlerId: '1', + id: '1', routeMatcher: { url: '*', }, diff --git a/packages/runner/cypress/fixtures/errors/intercept_spec.ts b/packages/runner/cypress/fixtures/errors/intercept_spec.ts index d4601de95f..3cfdca98f5 100644 --- a/packages/runner/cypress/fixtures/errors/intercept_spec.ts +++ b/packages/runner/cypress/fixtures/errors/intercept_spec.ts @@ -10,9 +10,9 @@ describe('cy.intercept', () => { .then(() => { Cypress.emit('net:event', 'before:request', { eventId: '1', - // @ts-ignore - routeHandlerId: Object.keys(Cypress.state('routes'))[0], subscription: { + // @ts-ignore + routeId: Object.keys(Cypress.state('routes'))[0], await: true, }, data: {}, @@ -31,22 +31,22 @@ describe('cy.intercept', () => { Cypress.emit('net:event', 'before:request', { eventId: '1', requestId: '1', - // @ts-ignore - routeHandlerId: Object.keys(Cypress.state('routes'))[0], subscription: { + // @ts-ignore + routeId: Object.keys(Cypress.state('routes'))[0], await: true, }, data: {}, }) - Cypress.emit('net:event', 'response', { + Cypress.emit('net:event', 'before:response', { eventId: '1', requestId: '1', - // @ts-ignore - routeHandlerId: Object.keys(Cypress.state('routes'))[0], subscription: { // @ts-ignore id: Object.values(Cypress.state('routes'))[0].requests['1'].subscriptions[0].subscription.id, + // @ts-ignore + routeId: Object.keys(Cypress.state('routes'))[0], await: true, }, data: {}, @@ -65,21 +65,20 @@ describe('cy.intercept', () => { Cypress.emit('net:event', 'before:request', { eventId: '1', requestId: '1', - // @ts-ignore - routeHandlerId: Object.keys(Cypress.state('routes'))[0], subscription: { + // @ts-ignore + routeId: Object.keys(Cypress.state('routes'))[0], await: true, }, data: {}, }) - Cypress.emit('net:event', 'after:response', { + Cypress.emit('net:event', 'network:error', { eventId: '1', requestId: '1', - // @ts-ignore - routeHandlerId: Object.keys(Cypress.state('routes'))[0], subscription: { - await: true, + // @ts-ignore + routeId: Object.keys(Cypress.state('routes'))[0], }, data: { error: { diff --git a/packages/runner/cypress/integration/reporter.errors.spec.js b/packages/runner/cypress/integration/reporter.errors.spec.js index 65657e177f..17bad2a069 100644 --- a/packages/runner/cypress/integration/reporter.errors.spec.js +++ b/packages/runner/cypress/integration/reporter.errors.spec.js @@ -288,7 +288,7 @@ describe('errors ui', () => { column: 24, codeFrameText: '.reply(()=>{', message: [ - 'A response callback passed to req.reply() threw an error while intercepting a response:', + 'A response handler threw an error while intercepting a response:', `expected 'b' to equal 'c'`, ], notInMessage: [ @@ -303,7 +303,7 @@ describe('errors ui', () => { // response failure from the network codeFrameText: '.wait(1000)', message: [ - 'req.reply() was provided a callback to intercept the upstream response, but a network error occurred while making the request', + 'A callback was provided to intercept the upstream response, but a network error occurred while making the request', ], notInMessage: [ 'The following error originated from your spec code', diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index 1b3b8da7fc..05387f221d 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -375,6 +375,7 @@ export class SocketBase { eventName: args[0], frame: args[1], state: options.netStubbingState, + socket: this, getFixture, args, }) diff --git a/scripts/binary/zip.js b/scripts/binary/zip.js index e171b592af..3283e72cb3 100644 --- a/scripts/binary/zip.js +++ b/scripts/binary/zip.js @@ -73,7 +73,7 @@ const checkZipSize = function (zipPath) { const zipSize = filesize(stats.size, { round: 0 }) console.log(`zip file size ${zipSize}`) - const MAX_ALLOWED_SIZE_MB = os.platform() === 'win32' ? 265 : 228 + const MAX_ALLOWED_SIZE_MB = os.platform() === 'win32' ? 265 : 230 const MAX_ZIP_FILE_SIZE = megaBytes(MAX_ALLOWED_SIZE_MB) if (stats.size > MAX_ZIP_FILE_SIZE) {