diff --git a/.gitignore b/.gitignore index 2fec763ebab1..44e4be97e09c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,20 +32,6 @@ packages/server/support packages/server/test/support/fixtures/server/imgs packages/server/test/support/fixtures/server/libs -# CLI tool -cli/types/blob-util -cli/types/bluebird -cli/types/chai -cli/types/chai-jquery -cli/types/jquery -cli/types/lodash -cli/types/mocha -cli/types/minimatch -cli/types/sinon -cli/types/sinon-chai -# ignore CLI output build folder -cli/build - # Building app binary scripts/support package-lock.json diff --git a/circle.yml b/circle.yml index 77f7e0a0bf56..c3ec578d87f4 100644 --- a/circle.yml +++ b/circle.yml @@ -549,7 +549,7 @@ jobs: # run unit tests from each individual package - run: yarn test - verify-mocha-results: - expectedResultCount: 8 + expectedResultCount: 9 - store_test_results: path: /tmp/cypress # CLI tests generate HTML files with sample CLI command output diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 000000000000..e288122700cc --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,14 @@ +types/blob-util +types/bluebird +types/chai +types/chai-jquery +types/jquery +types/lodash +types/mocha +types/minimatch +types/sinon +types/sinon-chai +# copied from net-stubbing package on build +types/net-stubbing.ts +# ignore CLI output build folder +build diff --git a/cli/package.json b/cli/package.json index e294ad3104ad..07835cd588a1 100644 --- a/cli/package.json +++ b/cli/package.json @@ -98,7 +98,8 @@ "bin", "lib", "index.js", - "types/**/*.d.ts" + "types/**/*.d.ts", + "types/net-stubbing.ts" ], "bin": { "cypress": "bin/cypress" diff --git a/cli/schema/cypress.schema.json b/cli/schema/cypress.schema.json index 12b7c37052e3..af8feb361d78 100644 --- a/cli/schema/cypress.schema.json +++ b/cli/schema/cypress.schema.json @@ -234,6 +234,11 @@ "default": false, "description": "Enables AST-based JS/HTML rewriting. This may fix issues caused by the existing regex-based JS/HTML replacement algorithm." }, + "experimentalNetworkMocking": { + "type": "boolean", + "default": false, + "description": "Enables `cy.route2`, which can be used to dynamically intercept/stub/await any HTTP request or response (XHRs, fetch, beacons, etc.)" + }, "experimentalShadowDomSupport": { "type": "boolean", "default": false, diff --git a/cli/scripts/post-install.js b/cli/scripts/post-install.js index fb1e0bb70569..a632fe82946f 100644 --- a/cli/scripts/post-install.js +++ b/cli/scripts/post-install.js @@ -68,3 +68,7 @@ makeReferenceTypesCommentRelative('sinon', '../sinon/index.d.ts', sinonChaiFilen // and an import sinon line to be changed to relative path shell.sed('-i', 'from \'sinon\';', 'from \'../sinon\';', sinonChaiFilename) + +// copy experimental network stubbing type definitions +// so users can import: `import 'cypress/types/net-stubbing'` +shell.cp(resolvePkg('@packages/net-stubbing/lib/external-types.ts'), 'types/net-stubbing.ts') diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index c579d8367c50..f1dbf19dd3da 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -26,6 +26,7 @@ declare namespace Cypress { * @see https://on.cypress.io/firefox-gc-issue */ (task: 'firefox:force:gc'): Promise + (task: 'net', eventName: string, frame: any): Promise } type BrowserName = 'electron' | 'chrome' | 'chromium' | 'firefox' | 'edge' | string @@ -2578,9 +2579,15 @@ declare namespace Cypress { * @default false */ experimentalSourceRewriting: boolean + /** + * Enables `cy.route2`, which can be used to dynamically intercept/stub/await any HTTP request or response (XHRs, fetch, beacons, etc.) + * @default false + */ + experimentalNetworkMocking: boolean /** * Enables shadow DOM support. Adds the `cy.shadow()` command and * the `includeShadowDom` option to some DOM commands. + * @default false */ experimentalShadowDomSupport: boolean /** diff --git a/cli/types/index.d.ts b/cli/types/index.d.ts index 64600eb51430..c66f78aa1187 100644 --- a/cli/types/index.d.ts +++ b/cli/types/index.d.ts @@ -27,6 +27,7 @@ // hmm, how to load it better? /// +/// /// /// /// diff --git a/package.json b/package.json index b81ebfd8fd89..c780e2fda3db 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "stop-only": "npx stop-only --skip .cy,.publish,.projects,node_modules,dist,dist-test,fixtures,lib,bower_components,src --exclude e2e.ts,cypress-tests.ts", "stop-only-all": "yarn stop-only --folder packages", "pretest": "yarn ensure-deps", - "test": "yarn lerna exec yarn test --scope cypress --scope \"'@packages/{electron,extension,https-proxy,launcher,network,proxy,rewriter,reporter,runner,socket}'\"", + "test": "yarn lerna exec yarn test --scope cypress --scope \"'@packages/{electron,extension,https-proxy,launcher,net-stubbing,network,proxy,rewriter,reporter,runner,socket}'\"", "test-debug": "lerna exec yarn test-debug --ignore \"'@packages/{desktop-gui,driver,root,static,web-config}'\"", "pretest-e2e": "yarn ensure-deps", "test-e2e": "lerna exec yarn test-e2e --ignore \"'@packages/{desktop-gui,driver,root,static,web-config}'\"", diff --git a/packages/driver/cypress.json b/packages/driver/cypress.json index 7eac07ef42f9..1d5269289822 100644 --- a/packages/driver/cypress.json +++ b/packages/driver/cypress.json @@ -8,5 +8,6 @@ "reporterOptions": { "configFile": "../../mocha-reporter-config.json" }, + "experimentalNetworkMocking": true, "experimentalShadowDomSupport": true } diff --git a/packages/driver/cypress/integration/commands/net_stubbing_spec.ts b/packages/driver/cypress/integration/commands/net_stubbing_spec.ts new file mode 100644 index 000000000000..20c24b39d745 --- /dev/null +++ b/packages/driver/cypress/integration/commands/net_stubbing_spec.ts @@ -0,0 +1,1569 @@ +describe('network stubbing', function () { + const { $, _, sinon, state, Promise } = Cypress + + beforeEach(function () { + cy.spy(Cypress.utils, 'warning') + }) + + context('cy.route2()', 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.route2(options, handler).then(function () { + const handlerId = _.findKey(state('routes'), { handler }) + const route = state('routes')[handlerId!] + + expectedEvent.handlerId = handlerId + 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('emits with url, body and stores Route', function () { + const handler = 'bar' + const url = 'http://foo.invalid' + const expectedEvent = { + routeMatcher: { + url: { + type: 'glob', + value: url, + }, + }, + staticResponse: { + body: 'bar', + }, + hasInterceptor: false, + } + + const expectedRoute = { + options: { url }, + 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, + }, + }, + hasInterceptor: true, + } + + const expectedRoute = { + options: { url }, + 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', + webSocket: false, + } + + 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, + }, + webSocket: options.webSocket, + }, + hasInterceptor: true, + } + + const expectedRoute = { + options, + handler, + } + + this.testRoute(options, handler, expectedEvent, expectedRoute) + }) + + // TODO: implement warning in cy.route2 if appropriate + // https://github.com/cypress-io/cypress/issues/2372 + it.skip('warns if a percent-encoded URL is used', function () { + cy.route2('GET', '/foo%25bar').then(function () { + expect(Cypress.utils.warning).to.be.calledWith('A URL with percent-encoded characters was passed to cy.route2(), but cy.route2() expects a decoded URL.\n\nDid you mean to pass "/foo%bar"?') + }) + }) + + // NOTE: see todo on 'warns if a percent-encoded URL is used' + it.skip('does not warn if an invalid percent-encoded URL is used', function () { + cy.route2('GET', 'http://example.com/%E0%A4%A').then(function () { + expect(Cypress.utils.warning).to.not.be.called + }) + }) + + context('logging', function () { + beforeEach(function () { + this.logs = [] + cy.on('log:added', (attrs, log) => { + if (attrs.instrument === 'route') { + this.lastLog = log + + this.logs.push(log) + } + }) + }) + + it('has name of route', function () { + cy.route2('/foo', {}).then(function () { + let lastLog + + lastLog = this.lastLog + + expect(lastLog.get('name')).to.eq('route') + }) + }) + + it('uses the wildcard URL', function () { + cy.route2('*', {}).then(function () { + let lastLog + + lastLog = this.lastLog + + expect(lastLog.get('url')).to.eq('*') + }) + }) + + // TODO: implement log niceties + it.skip('#consoleProps', function () { + cy.route2('*', { + foo: 'bar', + }).as('foo').then(function () { + expect(this.lastLog.invoke('consoleProps')).to.deep.eq({ + Command: 'route', + Method: 'GET', + URL: '*', + Status: 200, + Response: { + foo: 'bar', + }, + Alias: 'foo', + }) + }) + }) + + // Responded: 1 time + // "-------": "" + // Responses: [] + describe('numResponses', function () { + it('is initially 0', function () { + cy.route2(/foo/, {}).then(() => { + let lastLog + + lastLog = this.lastLog + + expect(lastLog.get('numResponses')).to.eq(0) + }) + }) + + it('is incremented to 2', function () { + cy.route2(/foo/, {}).then(function () { + $.get('/foo') + }).wrap(this).invoke('lastLog.get', 'numResponses').should('eq', 1) + }) + + it('is incremented for each matching request', function () { + cy.route2(/foo/, {}).then(function () { + return Promise.all([$.get('/foo'), $.get('/foo'), $.get('/foo')]) + }).then(function () { + expect(this.lastLog.get('numResponses')).to.eq(3) + }) + }) + }) + }) + + context('errors', function () { + beforeEach(function () { + this.logs = [] + + cy.on('log:added', (attrs, log) => { + this.lastLog = log + this.logs.push(log) + }) + }) + + it('if experimentalNetworkMocking is falsy', function (done) { + sinon.stub(Cypress, 'config').callThrough() + // @ts-ignore + .withArgs('experimentalNetworkMocking').returns(false) + + cy.on('fail', (err) => { + expect(err.message).to.contain('`cy.route2()` requires experimental network mocking to be enabled.') + sinon.restore() + done() + }) + + cy.route2('', '') + }) + + it('url must be a string or regexp', function (done) { + cy.on('fail', function (err) { + expect(err.message).to.include('`url` must be a string or a regular expression') + + done() + }) + + // @ts-ignore: should fail + cy.route2({ + url: {}, + }) + }) + + // TODO: not currently implemented + it.skip('fails when method is invalid', function (done) { + cy.on('fail', function (err) { + expect(err.message).to.include('cy.route2() was called with an invalid method: \'POSTS\'.') + + done() + }) + + cy.route2('posts', '/foo', {}) + }) + + it('requires a url when given a response', function (done) { + cy.on('fail', function (err) { + expect(err.message).to.include('The RouteMatcher does not contain any keys. You must pass something to match on.') + + done() + }) + + cy.route2({}) + }) + + it('requires arguments', function (done) { + cy.on('fail', function (err) { + expect(err.message).to.include('An invalid RouteMatcher was supplied to `cy.route2()`. The RouteMatcher does not contain any keys. You must pass something to match on.') + + done() + }) + + // @ts-ignore - should fail + cy.route2() + }) + + context('with invalid handler', function () { + [false, null].forEach(function (handler) { + const name = String(handler) + + it(`${name} fails`, function (done) { + cy.on('fail', (err) => { + expect(err).to.eq(this.lastLog.get('error')) + expect(err.message).to.contain(`You passed: ${name}`) + + done() + }) + + // @ts-ignore - this should error + cy.route2('/', handler) + }) + }) + }) + + context('with invalid StaticResponse', function () { + [ + [ + 'forceNetworkError set but not alone', + { + forceNetworkError: true, + body: 'aaa', + }, + 'must be the only option', + ], + [ + 'statusCode out of range', + { + statusCode: -1, + }, + '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) => { + expect(err).to.eq(this.lastLog.get('error')) + expect(err.message).to.contain(expectedErr) + expect(err.message).to.contain(`You passed: ${JSON.stringify(handler, null, 2)}`) + + done() + }) + + // @ts-ignore - this should error + cy.route2('/', handler) + }) + }) + }) + }) + }) + + context('stubbing with static responses', function () { + it('can stub a response with static body as string', function (done) { + cy.route2({ + url: '*', + }, 'hello world').then(() => { + $.get('/abc123').done((responseText, _, xhr) => { + expect(xhr.status).to.eq(200) + expect(responseText).to.eq('hello world') + + done() + }) + }) + }) + + it('can stub a cy.visit with static body', function () { + cy.route2('/foo', 'hello cruel world').visit('/foo').document().should('contain.text', 'hello cruel world') + }) + + it('can stub a response with an empty StaticResponse', function (done) { + cy.route2('/', {}).then(() => { + $.get('/').done((responseText, _, xhr) => { + expect(xhr.status).to.eq(200) + expect(responseText).to.eq('') + + done() + }) + }) + }) + + it('can stub a response with a network error', function (done) { + cy.route2('/', { + forceNetworkError: true, + }).then(() => { + $.get('/').fail((xhr) => { + expect(xhr.statusText).to.eq('error') + expect(xhr.status).to.eq(0) + + done() + }) + }) + }) + + it('can use regular strings as response', function () { + cy.route2('/foo', 'foo bar baz').as('getFoo').then(function (win) { + $.get('/foo') + }).wait('@getFoo').then(function (res) { + // TODO: determine if response bodies should be eagerly loaded for statically-defined routes + // expect(res.response.body).to.eq('foo bar baz') + }) + }) + + it('can stub requests with uncommon HTTP methods', function () { + cy.route2('PROPFIND', '/foo', 'foo bar baz').as('getFoo').then(function (win) { + $.ajax({ + url: '/foo', + method: 'PROPFIND', + }) + }).wait('@getFoo').then(function (res) { + // TODO: determine if response bodies should be eagerly loaded for statically-defined routes + // expect(res.response.body).to.eq('foo bar baz') + }) + }) + + it('still works after a cy.visit', function () { + cy.route2(/foo/, { + body: JSON.stringify({ foo: 'bar' }), + headers: { + 'content-type': 'application/json', + }, + }).as('getFoo').visit('http://localhost:3500/fixtures/jquery.html').window().then(function (win) { + return new Promise(function (resolve) { + $.get('/foo').done(_.ary(resolve, 0)) + }) + }).wait('@getFoo').its('request.url').should('include', '/foo').visit('http://localhost:3500/fixtures/generic.html').window().then(function (win) { + return new Promise(function (resolve) { + $.get('/foo').done(_.ary(resolve, 0)) + }) + }).wait('@getFoo').its('request.url').should('include', '/foo') + }) + + context('fixtures', function () { + it('can stub a response with a JSON object', function () { + cy.route2({ + method: 'POST', + url: '/test-xhr', + }, { + fixture: 'valid.json', + }).visit('/fixtures/xhr-triggered.html').get('#trigger-xhr').click() + + cy.contains('#result', '{"foo":1,"bar":{"baz":"cypress"}}').should('be.visible') + }) + + it('works with content-type override', function () { + cy.route2({ + method: 'POST', + url: '/test-xhr', + }, { + headers: { + 'content-type': 'text/plain', + }, + fixture: 'valid.json', + }).visit('/fixtures/xhr-triggered.html').get('#trigger-xhr').click() + + cy.contains('#result', '"{\\"foo\\":1,\\"bar\\":{\\"baz\\":\\"cypress\\"}}"').should('be.visible') + }) + + it('works if the JSON file has null content', function () { + cy.route2({ + method: 'POST', + url: '/test-xhr', + }, { + fixture: 'null.json', + }).visit('/fixtures/xhr-triggered.html').get('#trigger-xhr').click() + + cy.contains('#result', '""').should('be.visible') + }) + }) + }) + + context('intercepting request', function () { + it('receives the original request in handler', function (done) { + cy.route2('/def456', function (req) { + req.reply({ + statusCode: 404, + }) + + expect(req).to.include({ + method: 'GET', + httpVersion: '1.1', + }) + + expect(req.url).to.match(/^http:\/\/localhost:3500\/def456/) + + done() + }).then(function () { + $.get('/def456') + }) + }) + + it('receives the original request body in handler', function (done) { + cy.route2('/aaa', function (req) { + expect(req.body).to.eq('foo-bar-baz') + + done() + }).then(function () { + $.post('/aaa', 'foo-bar-baz') + }) + }) + + it('can modify original request body and have it passed to next handler', function (done) { + cy.route2('/post-only', function (req) { + expect(req.body).to.eq('foo-bar-baz') + req.body = 'quuz' + }).then(function () { + cy.route2('/post-only', function (req) { + expect(req.body).to.eq('quuz') + req.body = 'quux' + }) + }).then(function () { + cy.route2('/post-only', function (req) { + expect(req.body).to.eq('quux') + + done() + }) + }).then(function () { + $.post('/post-only', 'foo-bar-baz') + }) + }) + + it('can modify a cy.visit before it goes out', function () { + cy.route2('/dump-headers', function (req) { + expect(req.headers['foo']).to.eq('bar') + + req.headers['foo'] = 'quux' + }).then(function () { + cy.visit({ + url: '/dump-headers', + headers: { + 'foo': 'bar', + }, + }) + + cy.get('body').should('contain.text', '"foo":"quux"') + }) + }) + + it('can modify the request URL and headers', function (done) { + cy.route2('/does-not-exist', function (req) { + expect(req.headers['foo']).to.eq('bar') + req.url = 'http://localhost:3500/dump-headers' + + req.headers['foo'] = 'quux' + }).then(function () { + const xhr = new XMLHttpRequest() + + xhr.open('GET', '/does-not-exist') + xhr.setRequestHeader('foo', 'bar') + xhr.send() + + xhr.onload = () => { + expect(xhr.responseText).to.contain('"foo":"quux"') + + done() + } + }) + }) + + it('can modify the request method', function (done) { + cy.route2('/dump-method', function (req) { + expect(req.method).to.eq('POST') + + req.method = 'PATCH' + }).then(function () { + $.post('/dump-method').done((responseText) => { + expect(responseText).to.contain('request method: PATCH') + + done() + }) + }) + }) + + it('can modify the request body', function (done) { + const body = '{"foo":"bar"}' + + cy.route2('/post-only', function (req) { + expect(req.body).to.eq('quuz') + req.headers['content-type'] = 'application/json' + + req.body = body + }).then(function () { + $.post('/post-only', 'quuz').done((responseText) => { + expect(responseText).to.contain(body) + + done() + }) + }) + }) + + it('can add a body to a request that does not have one', function (done) { + const body = '{"foo":"bar"}' + + cy.route2('/post-only', function (req) { + expect(req.body).to.eq('') + expect(req.method).to.eq('GET') + req.method = 'POST' + req.headers['content-type'] = 'application/json' + + req.body = body + }).then(function () { + $.get('/post-only').done((responseText) => { + expect(responseText).to.contain(body) + + done() + }) + }) + }) + + it('can reply with a JSON fixture', function () { + cy.route2({ + method: 'POST', + url: '/test-xhr', + }, (req) => { + req.reply({ + fixture: 'valid.json', + }) + }).visit('/fixtures/xhr-triggered.html').get('#trigger-xhr').click() + + cy.contains('#result', '{"foo":1,"bar":{"baz":"cypress"}}').should('be.visible') + }) + + context('matches requests as expected', function () { + it('handles querystrings as expected', function () { + cy.route2({ + query: { + foo: 'b*r', + baz: /quu[x]/, + }, + }).as('first') + .route2({ + path: '/abc?foo=bar&baz=qu*x*', + }).as('second') + .route2({ + pathname: '/abc', + }).as('third') + .route2('*', 'it worked').as('final') + .then(() => { + return $.get('/abc?foo=bar&baz=quux') + }) + .wait('@first') + .wait('@second') + .wait('@third') + .wait('@final') + }) + }) + + context('with StaticResponse shorthand', function () { + it('req.reply(body)', function () { + cy.route2('/foo', function (req) { + req.reply('baz') + }) + .then(() => $.get('/foo')) + .should('eq', 'baz') + }) + + it('req.reply(json)', function () { + cy.route2('/foo', function (req) { + req.reply({ baz: 'quux' }) + }) + .then(() => $.getJSON('/foo')) + .should('deep.eq', { baz: 'quux' }) + }) + + it('req.reply(status)', function () { + cy.route2('/foo', function (req) { + req.reply(777) + }) + .then(() => { + return new Promise((resolve) => { + $.get('/foo').fail((x) => resolve(x.status)) + }) + }) + .should('eq', 777) + }) + + it('req.reply(status, body)', function () { + cy.route2('/foo', function (req) { + req.reply(777, 'bar') + }) + .then(() => { + return new Promise((resolve) => { + $.get('/foo').fail((xhr) => resolve(_.pick(xhr, 'status', 'responseText'))) + }) + }).should('include', { + status: 777, + responseText: 'bar', + }) + }) + + it('req.reply(status, json)', function () { + cy.route2('/foo', function (req) { + req.reply(777, { bar: 'baz' }) + }) + .then(() => { + return new Promise((resolve) => { + $.get('/foo').fail((xhr) => resolve(_.pick(xhr, 'status', 'responseJSON'))) + }) + }).should('deep.include', { + status: 777, + responseJSON: { bar: 'baz' }, + }) + }) + + it('req.reply(status, json, headers)', function () { + cy.route2('/foo', function (req) { + req.reply(777, { bar: 'baz' }, { 'x-quux': 'quuz' }) + }) + .then(() => { + return new Promise((resolve) => { + $.get('/foo').fail((xhr) => resolve(_.pick(xhr, 'status', 'responseJSON', 'getAllResponseHeaders'))) + }) + }).should('deep.include', { + status: 777, + responseJSON: { bar: 'baz' }, + }).invoke('getAllResponseHeaders') + .should('include', 'x-quux: quuz') + .and('include', 'content-type: application/json') + }) + + it('can forceNetworkError', function (done) { + cy.route2('/foo', function (req) { + req.reply({ forceNetworkError: true }) + }) + .then(() => { + $.get('/foo').fail((xhr) => { + expect(xhr).to.include({ + status: 0, + statusText: 'error', + readyState: 0, + }) + + done() + }) + }) + }) + }) + + context('request handler chaining', function () { + it('passes request through in order', function () { + cy.route2('/dump-method', function (req) { + expect(req.method).to.eq('GET') + req.method = 'POST' + }).route2('/dump-method', function (req) { + expect(req.method).to.eq('POST') + req.method = 'PATCH' + }).route2('/dump-method', function (req) { + expect(req.method).to.eq('PATCH') + + req.reply() + }).visit('/dump-method').contains('PATCH') + }) + + it('stops passing request through once req.reply called', function () { + cy.route2('/dump-method', function (req) { + expect(req.method).to.eq('GET') + req.method = 'POST' + }).route2('/dump-method', function (req) { + expect(req.method).to.eq('POST') + + req.reply() + }).visit('/dump-method').contains('POST') + }) + }) + + 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') + done() + }) + + cy.route2('/dump-method', function (req) { + req.reply() + + req.reply() + }).visit('/dump-method') + }) + + it('fails test if req.reply is called after req handler finishes', function (done) { + cy.on('fail', (err) => { + expect(err.message).to.contain('> `req.reply()` was called after the request handler finished executing') + done() + }) + + cy.route2('/dump-method', function (req) { + setTimeout(() => req.reply(), 50) + }).visit('/dump-method') + }) + + it('fails test if req.reply is called after req handler resolves', function (done) { + cy.on('fail', (err) => { + expect(err.message).to.contain('> `req.reply()` was called after the request handler finished executing') + done() + }) + + cy.route2('/dump-method', function (req) { + setTimeout(() => req.reply(), 50) + + return Promise.resolve() + }).visit('/dump-method') + }) + + it('fails test if an exception is thrown in req handler', function (done) { + cy.on('fail', (err2) => { + expect(err2.message).to.contain('A request callback passed to `cy.route2()` threw an error while intercepting a request') + .and.contain(err.message) + + done() + }) + + const err = new Error('bar') + + cy.route2('/foo', () => { + throw err + }).visit('/foo') + }) + + it('fails test if req.reply is called with an invalid StaticResponse', function (done) { + cy.on('fail', (err) => { + expect(err.message).to.contain('A request callback passed to `cy.route2()` threw an error while intercepting a request') + .and.contain('must be a number between 100 and 999 (inclusive).') + + done() + }) + + cy.route2('/foo', (req) => { + req.reply({ statusCode: 1 }) + }).visit('/foo') + }) + + it('can timeout in request handler', { + defaultCommandTimeout: 50, + }, function (done) { + cy.on('fail', (err) => { + expect(err.message).to.match(/^A request callback passed to `cy.route2\(\)` timed out after returning a Promise that took more than the `defaultCommandTimeout` of `50ms` to resolve\./) + + done() + }) + + cy.route2('/foo', () => { + return Promise.delay(200) + }).visit('/foo') + }) + }) + }) + + context('intercepting response', function () { + it('receives the original response in handler', function (done) { + cy.route2('/json-content-type', function (req) { + req.reply(function (res) { + expect(res.body).to.eq('{}') + + done() + }) + + expect(req.url).to.match(/^http:\/\/localhost:3500\/json-content-type/) + }).then(function () { + $.get('/json-content-type') + }) + }) + + it('intercepts redirects as expected', function () { + const href = `/fixtures/generic.html?t=${Date.now()}` + const url = `/redirect?href=${encodeURIComponent(href)}` + + cy.route2('/redirect', (req) => { + req.reply((res) => { + expect(res.statusCode).to.eq(301) + expect(res.headers.location).to.eq(href) + res.send() + }) + }) + .as('redirect') + .route2('/fixtures/generic.html').as('dest') + .then(() => fetch(url)) + .wait('@redirect') + .wait('@dest') + }) + + it('intercepts cached responses as expected', { + browser: '!firefox', // TODO: why does firefox behave differently? transparently returns cached response + }, function () { + // use a queryparam to bust cache from previous runs of this test + const url = `/fixtures/generic.html?t=${Date.now()}` + let hits = 0 + + cy.route2('/fixtures/generic.html', (req) => { + req.reply((res) => { + // the second time the request is sent, headers should have been passed + // that result in Express serving a 304 + // Cypress is not expected to understand cache mechanisms at this point - + // if the user wants to break caching, they can DIY by editing headers + const expectedStatusCode = [200, 304][hits] + + expect(expectedStatusCode).to.exist + expect(res.statusCode).to.eq(expectedStatusCode) + + hits++ + res.send() + }) + }) + .as('foo') + .then(() => _.times(2, () => fetch(url))) + .wait('@foo') + .wait('@foo') + .then(() => { + expect(hits).to.eq(2) + }) + }) + + it('can intercept a large proxy response', function (done) { + cy.route2('/1mb', (req) => { + req.reply((res) => { + res.send() + }) + }).then(() => { + $.get('/1mb').done((responseText) => { + // NOTE: the log from this when it fails is so long that it causes the browser to lock up :[ + expect(responseText).to.eq('X'.repeat(1024 * 1024)) + + done() + }) + }) + }) + + it('can delay a proxy response using res.delay', function (done) { + cy.route2('/timeout', (req) => { + req.reply((res) => { + this.start = Date.now() + + res.delay(1000).send('delay worked') + }) + }).then(() => { + $.get('/timeout') + .done((responseText) => { + expect(Date.now() - this.start).to.be.closeTo(1100, 100) + expect(responseText).to.include('delay worked') + + done() + }) + }) + }) + + it('can \'delay\' a proxy response using Promise.delay', function (done) { + cy.route2('/timeout', (req) => { + req.reply((res) => { + this.start = Date.now() + + return Promise.delay(1000) + .then(() => { + res.send('Promise.delay worked') + }) + }) + }).then(() => { + $.get('/timeout').then((responseText) => { + expect(Date.now() - this.start).to.be.closeTo(1000, 100) + expect(responseText).to.eq('Promise.delay worked') + + done() + }) + }) + }) + + it('can throttle a proxy response using res.throttle', function (done) { + cy.route2('/1mb', (req) => { + // don't let gzip make response smaller and throw off the timing + delete req.headers['accept-encoding'] + + req.reply((res) => { + this.start = Date.now() + + res.throttle(1024).send() + }) + }).then(() => { + $.get('/1mb').done((responseText) => { + // 1MB @ 1MB/s = ~1 second + expect(Date.now() - this.start).to.be.closeTo(1000, 250) + expect(responseText).to.eq('X'.repeat(1024 * 1024)) + + done() + }) + }) + }) + + it('can throttle a static response using res.throttle', function (done) { + const payload = 'A'.repeat(10 * 1024) + const kbps = 10 + const expectedSeconds = payload.length / (1024 * kbps) + + cy.route2('/timeout', (req) => { + req.reply((res) => { + this.start = Date.now() + + res.throttle(kbps).send(payload) + }) + }).then(() => { + $.get('/timeout').done((responseText) => { + expect(Date.now() - this.start).to.be.closeTo(expectedSeconds * 1000, 250) + expect(responseText).to.eq(payload) + + done() + }) + }) + }) + + it('can delay and throttle a static response', function (done) { + const payload = 'A'.repeat(10 * 1024) + const kbps = 20 + let expectedSeconds = payload.length / (1024 * kbps) + const delayMs = 500 + + expectedSeconds += delayMs / 1000 + + cy.route2('/timeout', (req) => { + req.reply((res) => { + this.start = Date.now() + + res.throttle(kbps).delay(delayMs).send({ + statusCode: 200, + body: payload, + }) + }) + }).then(() => { + $.get('/timeout').done((responseText) => { + expect(Date.now() - this.start).to.be.closeTo(expectedSeconds * 1000, 100) + expect(responseText).to.eq(payload) + + done() + }) + }) + }) + + it('can reply with a JSON fixture', function () { + cy.route2({ + method: 'POST', + url: '/test-xhr', + }, (req) => { + req.url = '/timeout' + req.method = 'GET' + req.reply((res) => { + res.send({ + headers: { + 'content-type': 'application/json', + }, + fixture: 'valid.json', + }) + }) + }).visit('/fixtures/xhr-triggered.html').get('#trigger-xhr').click() + + cy.contains('#result', '{"foo":1,"bar":{"baz":"cypress"}}').should('be.visible') + }) + + context('with StaticResponse shorthand', function () { + it('res.send(body)', function () { + cy.route2('/custom-headers', function (req) { + req.reply((res) => { + res.send('baz') + }) + }) + .then(() => { + return $.get('/custom-headers') + .then((_a, _b, xhr) => { + expect(xhr.status).to.eq(200) + expect(xhr.responseText).to.eq('baz') + expect(xhr.getAllResponseHeaders()) + .to.include('x-foo: bar') + }) + }) + }) + + it('res.send(json)', function () { + cy.route2('/custom-headers', function (req) { + req.reply((res) => { + res.send({ baz: 'quux' }) + }) + }) + .then(() => { + return $.getJSON('/custom-headers') + .then((data, _b, xhr) => { + expect(xhr.status).to.eq(200) + expect(xhr.getAllResponseHeaders()) + .to.include('x-foo: bar') + .and.include('content-type: application/json') + + expect(data).to.deep.eq({ baz: 'quux' }) + }) + }) + }) + + it('res.send(status)', function (done) { + cy.route2('/custom-headers', function (req) { + req.reply((res) => { + res.send(777) + }) + }) + .then(() => { + $.getJSON('/custom-headers') + .fail((xhr) => { + expect(xhr.status).to.eq(777) + expect(xhr.getAllResponseHeaders()) + .to.include('x-foo: bar') + + expect(xhr.responseText).to.include('hello there') + + done() + }) + }) + }) + + it('res.send(status, body)', function (done) { + cy.route2('/custom-headers', function (req) { + req.reply((res) => { + res.send(777, 'bar') + }) + }) + .then(() => { + $.get('/custom-headers') + .fail((xhr) => { + expect(xhr.status).to.eq(777) + expect(xhr.responseText).to.eq('bar') + expect(xhr.getAllResponseHeaders()) + .to.include('x-foo: bar') + + done() + }) + }) + }) + + it('res.send(status, json)', function (done) { + cy.route2('/custom-headers', function (req) { + req.reply((res) => { + res.send(777, { bar: 'baz' }) + }) + }) + .then(() => { + $.getJSON('/custom-headers') + .fail((xhr) => { + expect(xhr.status).to.eq(777) + expect(xhr.responseJSON).to.deep.eq({ bar: 'baz' }) + expect(xhr.getAllResponseHeaders()) + .to.include('x-foo: bar') + .and.include('content-type: application/json') + + done() + }) + }) + }) + + it('res.send(status, json, headers)', function (done) { + cy.route2('/custom-headers', function (req) { + req.reply((res) => { + res.send(777, { bar: 'baz' }, { 'x-quux': 'quuz' }) + }) + }) + .then(() => { + $.getJSON('/custom-headers') + .fail((xhr) => { + expect(xhr.status).to.eq(777) + expect(xhr.responseJSON).to.deep.eq({ bar: 'baz' }) + expect(xhr.getAllResponseHeaders()) + .to.include('x-foo: bar') // headers should be merged + .and.include('x-quux: quuz') + .and.include('content-type: application/json') + + done() + }) + }) + }) + + it('can forceNetworkError', function (done) { + cy.route2('/foo', function (req) { + req.reply((res) => { + res.send({ forceNetworkError: true }) + }) + }) + .then(() => { + $.get('/foo').fail((xhr) => { + expect(xhr).to.include({ + status: 0, + statusText: 'error', + readyState: 0, + }) + + done() + }) + }) + }) + }) + + context('errors', function () { + it('fails test if res.send is called twice in req handler', function (done) { + cy.on('fail', (err) => { + expect(err.message).to.contain('`res.send()` was called multiple times in a response handler, but the response can only be sent once.') + done() + }) + + cy.route2('/dump-method', function (req) { + req.reply(function (res) { + res.send() + + res.send() + }) + }).visit('/dump-method') + }) + + 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') + .and.contain(err.message) + + done() + }) + + const err = new Error('bar') + + cy.route2('/foo', (req) => { + req.reply(() => { + throw err + }) + }) + .then(() => { + $.get('/foo') + }) + .wait(1000) + }) + + 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') + .and.contain('must be a number between 100 and 999 (inclusive).') + + done() + }) + + cy.route2('/foo', (req) => { + req.reply((res) => { + res.send({ statusCode: 1 }) + }) + }) + .then(() => { + $.get('/foo') + }) + }) + + 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:') + .and.contain('Error: connect ECONNREFUSED 127.0.0.1:3333') + + done() + }) + + cy.route2('/should-err', function (req) { + req.reply(() => {}) + }).then(function () { + $.get('http://localhost:3333/should-err') + }) + }) + + it('doesn\'t fail test if network error occurs retrieving response and response is not intercepted', { + // TODO: for some reason, this test is busted in FF + browser: '!firefox', + }, function (done) { + cy.on('fail', (err) => { + // the test should have failed due to cy.wait, as opposed to because of a network error + expect(err.message).to.contain('Timed out retrying') + done() + }) + + cy.route2('/should-err', function (req) { + req.reply() + }) + .as('err') + .then(function () { + return new Promise((resolve) => { + $.get('http://localhost:3333/should-err') + .fail((xhr) => { + expect(xhr).to.include({ + status: 0, + statusText: 'error', + }) + + resolve() + }) + }) + }) + .wait('@err', { timeout: 50 }) + }) + + it('can timeout in req.reply handler', { + defaultCommandTimeout: 50, + }, function (done) { + cy.on('fail', (err) => { + 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\./) + + done() + }) + + cy.route2('/timeout', (req) => { + req.reply(() => { + return Promise.delay(200) + }) + }).visit('/timeout', { timeout: 500 }) + }) + + it('can timeout when retrieving upstream response', { + 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`\./) + .and.contain('ESOCKETTIMEDOUT') + + done() + }) + + cy.route2('/timeout', (req) => { + req.reply(_.noop) + }).then(() => { + $.get('/timeout?ms=50') + }) + }) + }) + }) + + context('waiting and aliasing', function () { + it('can wait on a single response using "alias"', function () { + cy.route2('/foo', 'bar') + .as('foo.bar') + .then(() => { + $.get('/foo') + }) + .wait('@foo.bar') + }) + + it('can timeout waiting on a single response using "alias"', function (done) { + cy.on('fail', (err) => { + expect(err.message).to.contain('No response ever occurred.') + done() + }) + + cy.route2('/foo', () => new Promise(_.noop)) + .as('foo.bar') + .then(() => { + $.get('/foo') + }) + .wait('@foo.bar', { timeout: 100 }) + }) + + it('can wait on a single response using "alias.response"', function () { + cy.route2('/foo', 'bar') + .as('foo.bar') + .then(() => { + $.get('/foo') + }) + .wait('@foo.bar.response') + }) + + it('can timeout waiting on a single response using "alias.response"', function (done) { + cy.on('fail', (err) => { + expect(err.message).to.contain('No response ever occurred.') + done() + }) + + cy.route2('/foo', () => new Promise(_.noop)) + .as('foo.bar') + .then(() => { + $.get('/foo') + }) + .wait('@foo.bar.response', { timeout: 100 }) + }) + + it('can wait on a single request using "alias.request"', function () { + cy.route2('/foo') + .as('foo.bar') + .then(() => { + $.get('/foo') + }) + .wait('@foo.bar.request') + }) + + it('can timeout waiting on a single request using "alias.request"', function (done) { + cy.on('fail', (err) => { + expect(err.message).to.contain('No request ever occurred.') + done() + }) + + cy.route2('/foo') + .as('foo.bar') + .wait('@foo.bar.request', { timeout: 100 }) + }) + + it('can incrementally wait on responses', function () { + cy.route2('/foo', 'bar') + .as('foo.bar') + .then(() => { + $.get('/foo') + }) + .wait('@foo.bar') + .then(() => { + $.get('/foo') + }) + .wait('@foo.bar') + }) + + it('can timeout incrementally waiting on responses', function (done) { + cy.on('fail', (err) => { + expect(err.message).to.contain('for the 1st response to the route') + done() + }) + + cy.route2('/foo', () => new Promise(_.noop)) + .as('foo.bar') + .then(() => { + $.get('/foo') + $.get('/foo') + }) + .wait('@foo.bar', { timeout: 100 }) + .wait('@foo.bar', { timeout: 100 }) + }) + + it('can incrementally wait on requests', function () { + cy.route2('/foo', (req) => { + req.reply(_.noop) // only request will be received, no response + }) + .as('foo.bar') + .then(() => { + $.get('/foo') + }) + .wait('@foo.bar.request') + .then(() => { + $.get('/foo') + }) + .wait('@foo.bar.request') + }) + + it('can timeout incrementally waiting on requests', function (done) { + cy.on('fail', (err) => { + expect(err.message).to.contain('for the 2nd request to the route') + done() + }) + + cy.route2('/foo', (req) => { + req.reply(_.noop) // only request will be received, no response + }) + .as('foo.bar') + .then(() => { + $.get('/foo') + }) + .wait('@foo.bar.request') + .wait('@foo.bar.request', { timeout: 100 }) + }) + + it('can alias a route without stubbing it', function () { + cy.route2(/fixtures\/app/).as('getFoo').then(function () { + $.get('/fixtures/app.json') + }).wait('@getFoo').then(function (res) { + let log + + log = cy.queue.logs({ + displayName: 'req', + })[0] + + expect(log.get('alias')).to.eq('getFoo') + + // TODO: determine if response bodies should be eagerly loaded for statically-defined routes + // expect(res.responseBody).to.deep.eq({ + // some: 'json', + // foo: { + // bar: 'baz', + // }, + // }) + }) + }) + + // NOTE: was undocumented in cy.route2, may not continue to support + // @see https://github.com/cypress-io/cypress/issues/7663 + context.skip('indexed aliases', function () { + it('can wait for things that do not make sense but are technically true', function () { + cy.route2('/foo') + .as('foo.bar') + .then(() => { + $.get('/foo') + }) + .wait('@foo.bar.1') + .wait('@foo.bar.1') // still only asserting on the 1st response + .wait('@foo.bar.request') // now waiting for the next request + }) + + it('can wait on the 3rd request using "alias.3"', function () { + cy.route2('/foo') + .as('foo.bar') + .then(() => { + _.times(3, () => { + $.get('/foo') + }) + }) + .wait('@foo.bar.3') + }) + + it('can timeout waiting on the 3rd request using "alias.3"', function (done) { + cy.on('fail', (err) => { + expect(err.message).to.contain('No response ever occurred.') + done() + }) + + cy.route2('/foo') + .as('foo.bar') + .then(() => { + _.times(2, () => { + $.get('/foo') + }) + }) + .wait('@foo.bar.3', { timeout: 100 }) + }) + }) + }) +}) diff --git a/packages/driver/cypress/integration/dom/jquery_spec.ts b/packages/driver/cypress/integration/dom/jquery_spec.ts index 012288c5d3b8..7ee3e86fba88 100644 --- a/packages/driver/cypress/integration/dom/jquery_spec.ts +++ b/packages/driver/cypress/integration/dom/jquery_spec.ts @@ -1,7 +1,3 @@ -declare interface Window { - jquery: Function -} - describe('src/dom/jquery', () => { context('.isJquery', () => { it('does not get confused when window contains jquery function', () => { diff --git a/packages/driver/cypress/plugins/server.js b/packages/driver/cypress/plugins/server.js index 6b756f85db34..d4dfd3e59120 100644 --- a/packages/driver/cypress/plugins/server.js +++ b/packages/driver/cypress/plugins/server.js @@ -35,6 +35,15 @@ ports.forEach((port) => { }) }) + app.get('/custom-headers', (req, res) => { + return res.set('x-foo', 'bar') + .send('hello there') + }) + + app.get('/redirect', (req, res) => { + res.redirect(301, req.query.href) + }) + // allows us to serve the testrunner into an iframe for testing app.use('/isolated-runner', express.static(path.join(__dirname, '../../../runner/dist'))) @@ -60,6 +69,10 @@ ports.forEach((port) => { }) }) + app.get('/1mb', (req, res) => { + return res.type('text').send('X'.repeat(1024 * 1024)) + }) + app.get('/basic_auth', (req, res) => { const user = auth(req) diff --git a/packages/driver/src/cy/commands/navigation.js b/packages/driver/src/cy/commands/navigation.js index 15f67911e3ec..9afe326af570 100644 --- a/packages/driver/src/cy/commands/navigation.js +++ b/packages/driver/src/cy/commands/navigation.js @@ -17,7 +17,7 @@ let hasVisitedAboutBlank = null let currentlyVisitingAboutBlank = null let knownCommandCausedInstability = null -const REQUEST_URL_OPTS = 'auth failOnStatusCode retryOnNetworkFailure retryOnStatusCodeFailure method body headers' +const REQUEST_URL_OPTS = 'auth failOnStatusCode retryOnNetworkFailure retryOnStatusCodeFailure method body headers selfProxy' .split(' ') const VISIT_OPTS = 'url log onBeforeLoad onLoad timeout requestTimeout' @@ -877,6 +877,11 @@ module.exports = (Commands, Cypress, cy, state, config) => { url = url.replace(`${existingAuth}@`, '') } + // hack to make cy.visits interceptable by network stubbing + if (Cypress.config('experimentalNetworkMocking')) { + options.selfProxy = true + } + return requestUrl(url, options) .then((resp = {}) => { let { url, originalUrl, cookies, redirects, filePath } = resp diff --git a/packages/driver/src/cy/commands/waiting.js b/packages/driver/src/cy/commands/waiting.js index 56606b26b330..d5e2e8fe15fa 100644 --- a/packages/driver/src/cy/commands/waiting.js +++ b/packages/driver/src/cy/commands/waiting.js @@ -1,5 +1,6 @@ const _ = require('lodash') const Promise = require('bluebird') +const { waitForRoute } = require('../net-stubbing') const ordinal = require('ordinal') const $errUtils = require('../../cypress/error_utils') @@ -61,24 +62,32 @@ module.exports = (Commands, Cypress, cy, state) => { }) } - const checkForXhr = function (alias, type, index, num, options) { + const checkForXhr = async function (alias, type, index, num, options) { + options.error = $errUtils.errByPath('wait.timed_out', { + timeout: options.timeout, + alias, + num, + type, + }) + options.type = type + if (Cypress.config('experimentalNetworkMocking')) { + const req = waitForRoute(alias, state, type) + + if (req) { + return req + } + } + // append .type to the alias const xhr = cy.getIndexedXhrByAlias(`${alias}.${type}`, index) // return our xhr object if (xhr) { - return Promise.resolve(xhr) + return xhr } - options.error = $errUtils.errByPath('wait.timed_out', { - timeout: options.timeout, - alias, - num, - type, - }).message - const args = [alias, type, index, num, options] return cy.retry(() => { @@ -87,19 +96,19 @@ module.exports = (Commands, Cypress, cy, state) => { } const waitForXhr = function (str, options) { - let str2 + let specifier // we always want to strip everything after the last '.' // since we support alias property 'request' if ((_.indexOf(str, '.') === -1) || _.keys(cy.state('aliases')).includes(str.slice(1))) { - str2 = null + specifier = null } else { // potentially request, response or index const allParts = _.split(str, '.') str = _.join(_.dropRight(allParts, 1), '.') - str2 = _.last(allParts) + specifier = _.last(allParts) } const aliasObj = cy.getAlias(str, 'wait', log) @@ -113,7 +122,7 @@ module.exports = (Commands, Cypress, cy, state) => { // by its alias const { alias, command } = aliasObj - str = _.compact([alias, str2]).join('.') + str = _.compact([alias, specifier]).join('.') const type = cy.getXhrTypeByAlias(str) @@ -137,7 +146,7 @@ module.exports = (Commands, Cypress, cy, state) => { log.set('referencesAlias', aliases) } - if (command.get('name') !== 'route') { + if (!['route', 'route2'].includes(command.get('name'))) { $errUtils.throwErrByPath('wait.invalid_alias', { onFail: options._log, args: { alias }, diff --git a/packages/driver/src/cy/net-stubbing/add-command.ts b/packages/driver/src/cy/net-stubbing/add-command.ts new file mode 100644 index 000000000000..e7a82a8d1a38 --- /dev/null +++ b/packages/driver/src/cy/net-stubbing/add-command.ts @@ -0,0 +1,285 @@ +import _ from 'lodash' + +import { + RouteHandler, + RouteMatcherOptions, + RouteMatcher, + StaticResponse, + HttpRequestInterceptor, + STRING_MATCHER_FIELDS, + DICT_STRING_MATCHER_FIELDS, + AnnotatedRouteMatcherOptions, + AnnotatedStringMatcher, + NetEventFrames, + StringMatcher, + NumberMatcher, +} from '@packages/net-stubbing/lib/types' +import { + validateStaticResponse, + getBackendStaticResponse, + hasStaticResponseKeys, +} from './static-response-utils' +import { registerEvents } from './events' +import $errUtils from '../../cypress/error_utils' + +/** + * Get all STRING_MATCHER_FIELDS paths plus any extra fields the user has added within + * DICT_STRING_MATCHER_FIELDS objects + */ +function getAllStringMatcherFields (options: RouteMatcherOptions): string[] { + // add the nested DictStringMatcher values to the list of fields to annotate + return _.chain(DICT_STRING_MATCHER_FIELDS) + .map((field): string[] | string => { + const value = options[field] + + if (value) { + // if this DICT_STRING_MATCHER is set, return a list of the prop paths + return _.keys(value).map((key) => { + return `${field}.${key}` + }) + } + + return '' + }) + .compact() + .flatten() + .concat(STRING_MATCHER_FIELDS) + .value() +} + +/** + * Annotate non-primitive types so that they can be passed to the backend and re-hydrated. + */ +function annotateMatcherOptionsTypes (options: RouteMatcherOptions) { + const ret: AnnotatedRouteMatcherOptions = {} + + getAllStringMatcherFields(options).forEach((field) => { + const value = _.get(options, field) + + if (value) { + _.set(ret, field, { + type: (isRegExp(value)) ? 'regex' : 'glob', + value: value.toString(), + } as AnnotatedStringMatcher) + } + }) + + const noAnnotationRequiredFields = ['https', 'port', 'webSocket'] + + _.extend(ret, _.pick(options, noAnnotationRequiredFields)) + + return ret +} + +function getUniqueId () { + return `${Number(new Date()).toString()}-${_.uniqueId()}` +} + +function isHttpRequestInterceptor (obj): obj is HttpRequestInterceptor { + return typeof obj === 'function' +} + +function isRegExp (obj): obj is RegExp { + return obj && (obj instanceof RegExp || obj.__proto__ === RegExp.prototype || obj.__proto__.constructor.name === 'RegExp') +} + +function isStringMatcher (obj): obj is StringMatcher { + return isRegExp(obj) || _.isString(obj) +} + +function isNumberMatcher (obj): obj is NumberMatcher { + return Array.isArray(obj) ? _.every(obj, _.isNumber) : _.isNumber(obj) +} + +function validateRouteMatcherOptions (routeMatcher: RouteMatcherOptions): { isValid: boolean, message?: string } { + const err = (message) => { + return { isValid: false, message } + } + + if (_.isEmpty(routeMatcher)) { + return err('The RouteMatcher does not contain any keys. You must pass something to match on.') + } + + const stringMatcherFields = getAllStringMatcherFields(routeMatcher) + + for (const path of stringMatcherFields) { + const v = _.get(routeMatcher, path) + + if (_.has(routeMatcher, path) && !isStringMatcher(v)) { + return err(`\`${path}\` must be a string or a regular expression.`) + } + } + + if (_.has(routeMatcher, 'https') && !_.isBoolean(routeMatcher.https)) { + return err('`https` must be a boolean.') + } + + if (_.has(routeMatcher, 'port') && !isNumberMatcher(routeMatcher.port)) { + return err('`port` must be a number or a list of numbers.') + } + + return { isValid: true } +} + +export function addCommand (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, state: Cypress.State) { + const { emitNetEvent } = registerEvents(Cypress) + + function getNewRouteLog (matcher: RouteMatcherOptions, isStubbed: boolean, alias: string | void, staticResponse?: StaticResponse) { + let obj: Partial = { + name: 'route', + instrument: 'route', + isStubbed, + numResponses: 0, + response: staticResponse ? (staticResponse.body || '< empty body >') : (isStubbed ? '< callback function >' : '< passthrough >'), + consoleProps: () => { + return { + Method: obj.method, + URL: obj.url, + Status: obj.status, + 'Route Matcher': matcher, + 'Static Response': staticResponse, + Alias: alias, + } + }, + } + + ;['method', 'url'].forEach((k) => { + if (matcher[k]) { + obj[k] = String(matcher[k]) // stringify RegExp + } else { + obj[k] = '*' + } + }) + + if (staticResponse) { + if (staticResponse.statusCode) { + obj.status = staticResponse.statusCode + } else { + obj.status = 200 + } + + if (staticResponse.body) { + obj.response = staticResponse.body + } else { + obj.response = '' + } + } + + if (!obj.response) { + if (isStubbed) { + obj.response = 'handler } + break + case _.isObjectLike(handler): + if (!hasStaticResponseKeys(handler)) { + // the user has not supplied any of the StaticResponse keys, assume it's a JSON object + // that should become the body property + handler = { + body: handler, + } + } + + validateStaticResponse('cy.route2', handler) + + staticResponse = handler as StaticResponse + break + default: + return $errUtils.throwErrByPath('net_stubbing.route2.invalid_handler', { args: { handler } }) + } + + const frame: NetEventFrames.AddRoute = { + handlerId, + hasInterceptor, + routeMatcher: annotateMatcherOptionsTypes(matcher), + } + + if (staticResponse) { + frame.staticResponse = getBackendStaticResponse(staticResponse) + } + + state('routes')[handlerId] = { + log: getNewRouteLog(matcher, !!handler, alias, staticResponse), + options: matcher, + handler, + hitCount: 0, + requests: {}, + } + + if (alias) { + state('routes')[handlerId].alias = alias + } + + return emitNetEvent('route:added', frame) + } + + function route2 (matcher: RouteMatcher, handler?: RouteHandler | StringMatcher, arg2?: RouteHandler) { + if (!Cypress.config('experimentalNetworkMocking')) { + return $errUtils.throwErrByPath('net_stubbing.route2.needs_experimental') + } + + function getMatcherOptions (): RouteMatcherOptions { + if (_.isString(matcher) && isStringMatcher(handler) && arg2) { + // method, url, handler + const url = handler as StringMatcher + + handler = arg2 + + return { + method: matcher, + url, + } + } + + if (isStringMatcher(matcher)) { + // url, handler + return { + url: matcher, + } + } + + return matcher + } + + const routeMatcherOptions = getMatcherOptions() + const { isValid, message } = validateRouteMatcherOptions(routeMatcherOptions) + + if (!isValid) { + $errUtils.throwErrByPath('net_stubbing.route2.invalid_route_matcher', { args: { message, matcher: routeMatcherOptions } }) + } + + return addRoute(routeMatcherOptions, handler as RouteHandler) + .then(() => null) + } + + Commands.addAll({ + route2, + }) +} diff --git a/packages/driver/src/cy/net-stubbing/events/index.ts b/packages/driver/src/cy/net-stubbing/events/index.ts new file mode 100644 index 000000000000..10d7b31fa588 --- /dev/null +++ b/packages/driver/src/cy/net-stubbing/events/index.ts @@ -0,0 +1,72 @@ +import { Route, Request } from '../types' +import { NetEventFrames } from '@packages/net-stubbing/lib/types' +import { onRequestReceived } from './request-received' +import { onResponseReceived } from './response-received' +import { onRequestComplete } from './request-complete' +import Bluebird from 'bluebird' + +export type HandlerFn = (Cypress: Cypress.Cypress, frame: Frame, opts: { + getRequest: (routeHandlerId: string, requestId: string) => Request | undefined + getRoute: (routeHandlerId: string) => Route | undefined + emitNetEvent: (eventName: string, frame: any) => Promise + failCurrentTest: (err: Error) => void +}) => Promise | void + +const netEventHandlers: { [eventName: string]: HandlerFn } = { + 'http:request:received': onRequestReceived, + 'http:response:received': onResponseReceived, + 'http:request:complete': onRequestComplete, +} + +export function registerEvents (Cypress: Cypress.Cypress) { + const { state } = Cypress + + function getRoute (routeHandlerId) { + return state('routes')[routeHandlerId] + } + + function getRequest (routeHandlerId: string, requestId: string): Request | undefined { + const route = getRoute(routeHandlerId) + + if (route) { + return route.requests[requestId] + } + + return + } + + function emitNetEvent (eventName: string, frame: any): Promise { + // all messages from driver to server are wrapped in backend:request + return Cypress.backend('net', eventName, frame) + } + + function failCurrentTest (err: Error) { + // @ts-ignore + // FIXME: asynchronous errors are not correctly attributed to spec when they come from `top`, must manually attribute + err.fromSpec = true + // @ts-ignore + // FIXME: throw inside of a setImmediate so that the error does not end up as an unhandled ~rejection~, since we do not correctly handle them + setImmediate(() => Cypress.cy.fail(err)) + } + + Cypress.on('test:before:run', () => { + // wipe out callbacks, requests, and routes when tests start + state('routes', {}) + }) + + Cypress.on('net:event', (eventName, frame: NetEventFrames.BaseHttp) => { + Bluebird.try(() => { + const handler = netEventHandlers[eventName] + + return handler(Cypress, frame, { + getRoute, + getRequest, + emitNetEvent, + failCurrentTest, + }) + }) + .catch(failCurrentTest) + }) + + return { emitNetEvent } +} diff --git a/packages/driver/src/cy/net-stubbing/events/request-complete.ts b/packages/driver/src/cy/net-stubbing/events/request-complete.ts new file mode 100644 index 000000000000..846c3c18a5b7 --- /dev/null +++ b/packages/driver/src/cy/net-stubbing/events/request-complete.ts @@ -0,0 +1,38 @@ +import { get } from 'lodash' +import { NetEventFrames } from '@packages/net-stubbing/lib/types' +import { errByPath, makeErrFromObj } from '../../../cypress/error_utils' +import { HandlerFn } from './' + +export const onRequestComplete: HandlerFn = (Cypress, frame, { failCurrentTest, getRequest, getRoute }) => { + const request = getRequest(frame.routeHandlerId, frame.requestId) + + if (!request) { + return + } + + if (frame.error) { + const isTimeoutError = frame.error.code && ['ESOCKETTIMEDOUT', 'ETIMEDOUT'].includes(frame.error.code) + const errorName = isTimeoutError ? 'timeout' : 'network_error' + + const err = errByPath(`net_stubbing.request_error.${errorName}`, { + innerErr: makeErrFromObj(frame.error), + req: request.request, + route: get(getRoute(frame.routeHandlerId), 'options'), + }) + + request.state = 'Errored' + request.log.snapshot('error').error(err) + + if (request.responseHandler) { + // if req.reply was used to register a response handler, the user is implicitly + // expecting there to be a successful response from the server, so fail the test + // since a network error has occured + return failCurrentTest(err) + } + + return + } + + request.state = 'Complete' + request.log.snapshot('response').end() +} diff --git a/packages/driver/src/cy/net-stubbing/events/request-received.ts b/packages/driver/src/cy/net-stubbing/events/request-received.ts new file mode 100644 index 000000000000..953b63b59ade --- /dev/null +++ b/packages/driver/src/cy/net-stubbing/events/request-received.ts @@ -0,0 +1,200 @@ +import _ from 'lodash' + +import { + Route, + Request, + CyHttpMessages, + StaticResponse, + SERIALIZABLE_REQ_PROPS, + NetEventFrames, +} from '../types' +import { + validateStaticResponse, + getBackendStaticResponse, + parseStaticResponseShorthand, +} from '../static-response-utils' +import $errUtils from '../../../cypress/error_utils' +import { HandlerFn } from './' +import Bluebird from 'bluebird' + +export const onRequestReceived: HandlerFn = (Cypress, frame, { getRoute, emitNetEvent }) => { + function getRequestLog (route: Route, request: Omit) { + return Cypress.log({ + name: 'xhr', + displayName: 'req', + alias: route.alias, + aliasType: 'route', + type: 'parent', + event: true, + consoleProps: () => { + return { + Alias: route.alias, + Method: request.request.method, + URL: request.request.url, + Matched: route.options, + Handler: route.handler, + } + }, + renderProps: () => { + return { + indicator: request.state === 'Complete' ? 'successful' : 'pending', + message: `${request.request.url} ${request.state}`, + } + }, + }) + } + + const route = getRoute(frame.routeHandlerId) + const { req, requestId, routeHandlerId } = frame + + const sendContinueFrame = () => { + if (continueSent) { + throw new Error('sendContinueFrame called twice in handler') + } + + continueSent = true + + if (request) { + request.state = 'Intercepted' + } + + if (continueFrame) { + // copy changeable attributes of userReq to req in frame + // @ts-ignore + continueFrame.req = { + ..._.pick(userReq, SERIALIZABLE_REQ_PROPS), + } + + _.merge(request.request, continueFrame.req) + + emitNetEvent('http:request:continue', continueFrame) + } + } + + if (!route) { + return sendContinueFrame() + } + + const request: Partial = { + id: requestId, + request: req, + state: 'Received', + } + + request.log = getRequestLog(route, request as Omit) + request.log.snapshot('request') + + // 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 Request + + if (frame.notificationOnly) { + return + } + + const continueFrame: Partial = { + routeHandlerId, + requestId, + } + + let resolved = false + let replyCalled = false + let continueSent = false + + route.hitCount++ + + const userReq: CyHttpMessages.IncomingHttpRequest = { + ...req, + reply (responseHandler, maybeBody?, maybeHeaders?) { + if (resolved) { + return $errUtils.throwErrByPath('net_stubbing.request_handling.reply_called_after_resolved') + } + + if (replyCalled) { + return $errUtils.throwErrByPath('net_stubbing.request_handling.multiple_reply_calls') + } + + replyCalled = true + + const staticResponse = parseStaticResponseShorthand(responseHandler, maybeBody, maybeHeaders) + + if (staticResponse) { + 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 + continueFrame.hasResponseHandler = true + userReq.responseTimeout = userReq.responseTimeout || Cypress.config('responseTimeout') + + return sendContinueFrame() + } + + if (!_.isUndefined(responseHandler)) { + // `replyHandler` is a StaticResponse + validateStaticResponse('req.reply', responseHandler) + + continueFrame.staticResponse = getBackendStaticResponse(responseHandler as StaticResponse) + } + + return sendContinueFrame() + }, + redirect (location, statusCode = 302) { + userReq.reply({ + headers: { location }, + statusCode, + }) + }, + destroy () { + userReq.reply({ + forceNetworkError: true, + }) + }, + } + + if (!_.isFunction(route.handler)) { + return sendContinueFrame() + } + + const handler = route.handler as Function + + const timeout = Cypress.config('defaultCommandTimeout') + + // if a Promise is returned, wait for it to resolve. if req.reply() + // has not been called, continue to the next interceptor + return Bluebird.try(() => { + return handler(userReq) + }) + .catch((err) => { + $errUtils.throwErrByPath('net_stubbing.request_handling.cb_failed', { + args: { + err, + req, + route: route.options, + }, + errProps: { + appendToStack: { + title: 'From request callback', + content: err.stack, + }, + }, + }) + }) + .timeout(timeout) + .catch(Bluebird.TimeoutError, (err) => { + $errUtils.throwErrByPath('net_stubbing.request_handling.cb_timeout', { args: { timeout, req, route: route.options } }) + }) + .finally(() => { + resolved = true + }) + .then(() => { + if (!replyCalled) { + // handler function resolved without resolving request, pass on + continueFrame.tryNextRoute = true + sendContinueFrame() + } + }) +} diff --git a/packages/driver/src/cy/net-stubbing/events/response-received.ts b/packages/driver/src/cy/net-stubbing/events/response-received.ts new file mode 100644 index 000000000000..ea5b550f38f8 --- /dev/null +++ b/packages/driver/src/cy/net-stubbing/events/response-received.ts @@ -0,0 +1,136 @@ +import _ from 'lodash' + +import { + CyHttpMessages, + SERIALIZABLE_RES_PROPS, + NetEventFrames, +} from '@packages/net-stubbing/lib/types' +import { + validateStaticResponse, + parseStaticResponseShorthand, + STATIC_RESPONSE_KEYS, + getBackendStaticResponse, +} from '../static-response-utils' +import $errUtils from '../../../cypress/error_utils' +import { HandlerFn } from './' +import Bluebird from 'bluebird' + +export const onResponseReceived: HandlerFn = (Cypress, frame, { getRoute, getRequest, emitNetEvent }) => { + const { res, requestId, routeHandlerId } = frame + const request = getRequest(frame.routeHandlerId, frame.requestId) + + let sendCalled = false + let resolved = false + + if (request) { + request.state = 'ResponseReceived' + } + + const continueFrame: NetEventFrames.HttpResponseContinue = { + routeHandlerId, + requestId, + } + + const sendContinueFrame = () => { + // copy changeable attributes of userReq to req in frame + // @ts-ignore + request.response = continueFrame.res = { + ..._.pick(userRes, SERIALIZABLE_RES_PROPS), + } + + if (request) { + request.state = 'ResponseIntercepted' + } + + emitNetEvent('http:response:continue', continueFrame) + } + + const userRes: CyHttpMessages.IncomingHttpResponse = { + ...res, + send (staticResponse?, maybeBody?, maybeHeaders?) { + if (resolved) { + return $errUtils.throwErrByPath('net_stubbing.response_handling.send_called_after_resolved', { args: { res } }) + } + + if (sendCalled) { + return $errUtils.throwErrByPath('net_stubbing.response_handling.multiple_send_calls', { args: { res } }) + } + + sendCalled = true + + const shorthand = parseStaticResponseShorthand(staticResponse, maybeBody, maybeHeaders) + + if (shorthand) { + staticResponse = shorthand + } + + if (staticResponse) { + validateStaticResponse('res.send', staticResponse) + + continueFrame.staticResponse = getBackendStaticResponse( + // arguments to res.send() are merged with the existing response + _.defaultsDeep({}, staticResponse, _.pick(res, STATIC_RESPONSE_KEYS)), + ) + } + + return sendContinueFrame() + }, + delay (delayMs) { + // reduce perceived delay by sending timestamp instead of offset + continueFrame.continueResponseAt = Date.now() + delayMs + + return this + }, + throttle (throttleKbps) { + continueFrame.throttleKbps = throttleKbps + + return this + }, + } + + if (!request) { + return sendContinueFrame() + } + + const timeout = Cypress.config('defaultCommandTimeout') + + return Bluebird.try(() => { + return request.responseHandler!(userRes) + }) + .catch((err) => { + $errUtils.throwErrByPath('net_stubbing.response_handling.cb_failed', { + args: { + err, + req: request.request, + route: _.get(getRoute(routeHandlerId), 'options'), + res, + }, + errProps: { + appendToStack: { + title: 'From response callback', + content: err.stack, + }, + }, + }) + }) + .timeout(timeout) + .catch(Bluebird.TimeoutError, (err) => { + $errUtils.throwErrByPath('net_stubbing.response_handling.cb_timeout', { + args: { + timeout, + req: request.request, + route: _.get(getRoute(routeHandlerId), 'options'), + res, + }, + }) + }) + .then(() => { + if (!sendCalled) { + // user did not call send, send response + userRes.send() + } + }) + .finally(() => { + resolved = true + }) +} diff --git a/packages/driver/src/cy/net-stubbing/index.ts b/packages/driver/src/cy/net-stubbing/index.ts new file mode 100644 index 000000000000..531f694d1f75 --- /dev/null +++ b/packages/driver/src/cy/net-stubbing/index.ts @@ -0,0 +1,3 @@ +export { addCommand } from './add-command' + +export { waitForRoute } from './wait-for-route' diff --git a/packages/driver/src/cy/net-stubbing/static-response-utils.ts b/packages/driver/src/cy/net-stubbing/static-response-utils.ts new file mode 100644 index 000000000000..cbded34cb0fb --- /dev/null +++ b/packages/driver/src/cy/net-stubbing/static-response-utils.ts @@ -0,0 +1,103 @@ +import _ from 'lodash' + +import { + StaticResponse, + BackendStaticResponse, + FixtureOpts, + GenericStaticResponse, +} from '@packages/net-stubbing/lib/types' +import $errUtils from '../../cypress/error_utils' + +export const STATIC_RESPONSE_KEYS: (keyof GenericStaticResponse)[] = ['body', 'fixture', 'statusCode', 'headers', 'forceNetworkError'] + +export function validateStaticResponse (cmd: string, staticResponse: StaticResponse): void { + const err = (message) => { + $errUtils.throwErrByPath('net_stubbing.invalid_static_response', { args: { cmd, message, staticResponse } }) + } + + const { body, fixture, statusCode, headers, forceNetworkError } = staticResponse + + if (forceNetworkError && (body || statusCode || headers)) { + err('`forceNetworkError`, if passed, must be the only option in the StaticResponse.') + } + + if (body && fixture) { + err('`body` and `fixture` cannot both be set, pick one.') + } + + if (fixture && !_.isString(fixture)) { + err('`fixture` must be a string containing a path and, optionally, an encoding separated by a comma (for example, "foo.txt,ascii").') + } + + // statusCode must be a three-digit integer + // @see https://tools.ietf.org/html/rfc2616#section-6.1.1 + if (statusCode && !(_.isNumber(statusCode) && _.inRange(statusCode, 100, 999))) { + err('`statusCode` must be a number between 100 and 999 (inclusive).') + } + + if (headers && _.keys(_.omitBy(headers, _.isString)).length) { + err('`headers` must be a map of strings to strings.') + } +} + +export function parseStaticResponseShorthand (statusCodeOrBody: number | string | any, bodyOrHeaders: string | { [key: string]: string }, maybeHeaders?: { [key: string]: string }) { + if (_.isNumber(statusCodeOrBody)) { + // statusCodeOrBody is a status code + const staticResponse: StaticResponse = { + statusCode: statusCodeOrBody, + } + + if (!_.isUndefined(bodyOrHeaders)) { + staticResponse.body = bodyOrHeaders as string + } + + if (_.isObject(maybeHeaders)) { + staticResponse.headers = maybeHeaders as { [key: string]: string } + } + + return staticResponse + } + + if ((_.isString(statusCodeOrBody) || !hasStaticResponseKeys(statusCodeOrBody)) && !maybeHeaders) { + const staticResponse: StaticResponse = { + body: statusCodeOrBody, + } + + if (_.isObject(bodyOrHeaders)) { + staticResponse.headers = bodyOrHeaders as { [key: string]: string } + } + + return staticResponse + } + + return +} + +function getFixtureOpts (fixture: string): FixtureOpts { + const [filePath, encoding] = fixture.split(',') + + return { filePath, encoding } +} + +export function getBackendStaticResponse (staticResponse: Readonly): BackendStaticResponse { + const backendStaticResponse: BackendStaticResponse = _.omit(staticResponse, 'body', 'fixture') + + if (staticResponse.fixture) { + backendStaticResponse.fixture = getFixtureOpts(staticResponse.fixture) + } + + if (staticResponse.body) { + if (_.isString(staticResponse.body)) { + backendStaticResponse.body = staticResponse.body + } else { + backendStaticResponse.body = JSON.stringify(staticResponse.body) + _.set(backendStaticResponse, 'headers.content-type', 'application/json') + } + } + + return backendStaticResponse +} + +export function hasStaticResponseKeys (obj: any) { + return _.intersection(_.keys(obj), STATIC_RESPONSE_KEYS).length || _.isEmpty(obj) +} diff --git a/packages/driver/src/cy/net-stubbing/types.ts b/packages/driver/src/cy/net-stubbing/types.ts new file mode 100644 index 000000000000..3c847477764d --- /dev/null +++ b/packages/driver/src/cy/net-stubbing/types.ts @@ -0,0 +1 @@ +export * from '@packages/net-stubbing/lib/types' diff --git a/packages/driver/src/cy/net-stubbing/wait-for-route.ts b/packages/driver/src/cy/net-stubbing/wait-for-route.ts new file mode 100644 index 000000000000..19cb3495db56 --- /dev/null +++ b/packages/driver/src/cy/net-stubbing/wait-for-route.ts @@ -0,0 +1,55 @@ +import _ from 'lodash' +import { + Request, + Route, + RequestState, +} from './types' + +const RESPONSE_WAITED_STATES: RequestState[] = ['ResponseIntercepted', 'Complete'] + +export function waitForRoute (alias: string, state: Cypress.State, specifier: 'request' | 'response' | string): Request | null { + // if they didn't specify what to wait on, they want to wait on a response + if (!specifier) { + specifier = 'response' + } + + // 1. Get route with this alias. + const route: Route = _.find(state('routes'), { alias }) + + if (!route) { + // TODO: once XHR stubbing is removed, this should throw + return null + } + + // 2. Find the first request without responseWaited/requestWaited/with the correct index + let i = 0 + const request = _.find(route.requests, (request) => { + i++ + switch (specifier) { + case 'request': + return !request.requestWaited + case 'response': + return !request.responseWaited + default: + return i === Number(specifier) + } + }) + + if (!request) { + return null + } + + // 3. Determine if it's ready based on the specifier + request.requestWaited = true + if (specifier === 'request') { + return request + } + + if (RESPONSE_WAITED_STATES.includes(request.state)) { + request.responseWaited = true + + return request + } + + return null +} diff --git a/packages/driver/src/cypress/commands.js b/packages/driver/src/cypress/commands.js index 69648be20681..89d6c737bbd8 100644 --- a/packages/driver/src/cypress/commands.js +++ b/packages/driver/src/cypress/commands.js @@ -37,6 +37,7 @@ const builtInCommands = [ require('../cy/commands/waiting'), require('../cy/commands/window'), require('../cy/commands/xhr'), + require('../cy/net-stubbing').addCommand, ] const getTypeByPrevSubject = (prevSubject) => { diff --git a/packages/driver/src/cypress/error_messages.js b/packages/driver/src/cypress/error_messages.js index 15bf4cabf091..3373e31a8ef1 100644 --- a/packages/driver/src/cypress/error_messages.js +++ b/packages/driver/src/cypress/error_messages.js @@ -1,6 +1,7 @@ const _ = require('lodash') const { stripIndent } = require('common-tags') const capitalize = require('underscore.string/capitalize') +const { normalizedStack } = require('./stack_utils') const divider = (num, char) => { return Array(num).join(char) @@ -942,6 +943,119 @@ module.exports = { }, }, + net_stubbing: { + invalid_static_response: ({ cmd, message, staticResponse }) => { + return cyStripIndent(`\ + An invalid StaticResponse was supplied to \`${cmd}()\`. ${message} + + You passed: ${format(staticResponse)}`, 8) + }, + route2: { + needs_experimental: stripIndent`\ + ${cmd('route2')} requires experimental network mocking to be enabled. + + Set the \`experimentalNetworkMocking\` config value to \`true\` to access this command. + + Read more: https://on.cypress.io/experiments`, + invalid_handler: ({ handler }) => { + return stripIndent`\ + ${cmd('route2')}'s \`handler\` argument must be a String, StaticResponse, or HttpController function. + + You passed: ${format(handler)}` + }, + invalid_route_matcher: ({ message, matcher }) => { + return stripIndent`\ + An invalid RouteMatcher was supplied to ${cmd('route2')}. ${message} + + You passed: ${format(matcher)}` + }, + }, + request_handling: { + cb_failed: ({ err, req, route }) => { + return cyStripIndent(`\ + A request callback passed to ${cmd('route2')} threw an error while intercepting a request: + + ${err.message} + + Route: ${format(route)} + + Intercepted request: ${format(req)}`, 10) + }, + cb_timeout: ({ timeout, req, route }) => { + return cyStripIndent(`\ + A request callback passed to ${cmd('route2')} timed out after returning a Promise that took more than the \`defaultCommandTimeout\` of \`${timeout}ms\` to resolve. + + If the request callback is expected to take longer than \`${timeout}ms\`, increase the configured \`defaultCommandTimeout\` value. + + Route: ${format(route)} + + 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.`, + }, + 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: + + ${normalizedStack(innerErr)} + + Route: ${format(route)} + + Intercepted request: ${format(req)}`, 10) + }, + 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\`. + + ${normalizedStack(innerErr)} + + Route: ${format(route)} + + Intercepted request: ${format(req)}`, 10) + }, + }, + response_handling: { + cb_failed: ({ err, req, res, route }) => { + return cyStripIndent(`\ + A response callback passed to \`req.reply()\` threw an error while intercepting a response: + + ${err.message} + + Route: ${format(route)} + + Intercepted request: ${format(req)} + + Intercepted response: ${format(res)}`, 10) + }, + 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. + + If the response callback is expected to take longer than \`${timeout}ms\`, increase the configured \`defaultCommandTimeout\` value. + + Route: ${format(route)} + + Intercepted request: ${format(req)} + + Intercepted response: ${format(res)}`, 10) + }, + multiple_send_calls: ({ res }) => { + return cyStripIndent(`\ + \`res.send()\` was called multiple times in a response handler, but the response can only be sent once. + + Response: ${format(res)}`, 10) + }, + send_called_after_resolved: ({ res }) => { + return cyStripIndent(`\ + \`res.send()\` was called after the response handler finished executing, but \`res.send()\` can not be called after the response has been passed on. + + Intercepted response: ${format(res)}`, 10) + }, + }, + }, + ng: { no_global: `Angular global (\`window.angular\`) was not found in your window. You cannot use ${cmd('ng')} methods without angular.`, }, diff --git a/packages/driver/src/cypress/error_utils.js b/packages/driver/src/cypress/error_utils.js index cf312b924fa1..e5114a1fbba0 100644 --- a/packages/driver/src/cypress/error_utils.js +++ b/packages/driver/src/cypress/error_utils.js @@ -254,7 +254,8 @@ const errByPath = (msgPath, args) => { } const createUncaughtException = (type, err) => { - const errPath = type === 'spec' ? 'uncaught.fromSpec' : 'uncaught.fromApp' + // FIXME: `fromSpec` is a dirty hack to get uncaught exceptions in `top` to say they're from the spec + const errPath = (type === 'spec' || err.fromSpec) ? 'uncaught.fromSpec' : 'uncaught.fromApp' let uncaughtErr = errByPath(errPath, { errMsg: err.message, }) diff --git a/packages/driver/src/dom/elements.ts b/packages/driver/src/dom/elements.ts index 50c6ff7e003d..a58244d3df7f 100644 --- a/packages/driver/src/dom/elements.ts +++ b/packages/driver/src/dom/elements.ts @@ -55,6 +55,7 @@ declare global { SVGElement: typeof SVGElement EventTarget: typeof EventTarget Document: typeof Document + XMLHttpRequest: typeof XMLHttpRequest } interface Selection { diff --git a/packages/driver/types/internal-types.d.ts b/packages/driver/types/internal-types.d.ts index 341f9c9de6d0..eee093b9b2de 100644 --- a/packages/driver/types/internal-types.d.ts +++ b/packages/driver/types/internal-types.d.ts @@ -2,16 +2,68 @@ // TODO: find a better place for this declare namespace Cypress { + interface Actions { + (action: 'net:event', frame: any) + } + + interface cy { + /** + * If `as` is chained to the current command, return the alias name used. + */ + getNextAlias: () => string | undefined + noop: (v: T) => Cypress.Chainable + queue: any + retry: (fn: () => any, opts: any) => any + state: State + } + interface Cypress { + backend: (eventName: string, ...args: any[]) => Promise // TODO: how to pull these from resolvers.ts? can't import in a d.ts file... resolveWindowReference: any resolveLocationReference: any - state: Cypress.state + routes: { + [routeHandlerId: string]: any + } + sinon: sinon.SinonApi + utils: CypressUtils + state: State } - // Cypress.state is also accessible on cy.state - interface cy { - state: Cypress.State + interface CypressUtils { + throwErrByPath: (path: string, obj?: { args: object }) => void + warnByPath: (path: string, obj?: { args: object }) => void + warning: (message: string) => void + } + + type Log = ReturnType + + interface LogConfig { + message: any[] + instrument?: 'route' + isStubbed?: boolean + alias?: string + aliasType?: 'route' + type?: 'parent' + event?: boolean + method?: string + url?: string + status?: number + numResponses?: number + response?: string | object + renderProps?: () => { + indicator?: 'aborted' | 'pending' | 'successful' | 'bad' + message?: string + } + } + + interface State { + (k: '$autIframe', v?: JQuery): JQuery | undefined + (k: 'routes', v?: RouteMap): RouteMap + (k: 'document', v?: Document): Document + (k: 'window', v?: Window): Window + (k: string, v?: any): any + state: Cypress.state } // Extend Cypress.state properties here diff --git a/packages/driver/types/window.d.ts b/packages/driver/types/window.d.ts new file mode 100644 index 000000000000..118bfe907895 --- /dev/null +++ b/packages/driver/types/window.d.ts @@ -0,0 +1,4 @@ +declare interface Window { + jquery: Function + $: JQueryStatic +} diff --git a/packages/net-stubbing/README.md b/packages/net-stubbing/README.md new file mode 100644 index 000000000000..f4399f277758 --- /dev/null +++ b/packages/net-stubbing/README.md @@ -0,0 +1,25 @@ +# net-stubbing + +This package contains the server-side code and type definitions for Cypress's network stubbing feature. + +Driver-side code is contained in the `driver` package. + +## Building + +Note: you should not ever need to build the .js files manually. `@packages/ts` provides require-time transpilation when in development. + +```shell +yarn build-prod +``` + +## Testing + +Tests are located in [`./test`](./test) + +To run tests: + +```shell +yarn test +``` + +Additionally, `net_stubbing_spec` in the `driver` package tests the functionality in this repo. diff --git a/packages/net-stubbing/lib/external-types.ts b/packages/net-stubbing/lib/external-types.ts new file mode 100644 index 000000000000..bd5807e10426 --- /dev/null +++ b/packages/net-stubbing/lib/external-types.ts @@ -0,0 +1,213 @@ +/** + * HTTP request/response types. + */ +export namespace CyHttpMessages { + interface BaseMessage { + // as much stuff from `incomingmessage` as makes sense to serialize and send + body?: any + headers: { [key: string]: string } + url: string + method?: string + httpVersion?: string + } + + export type IncomingResponse = BaseMessage & { + statusCode: number + statusMessage: string + } + + export type IncomingHttpResponse = IncomingResponse & { + /** + * Continue the HTTP response, merging the supplied values with the real response. + */ + send(status: number, body?: string | number | object, headers?: { [key: string]: string }): void + send(body: string | object, headers?: { [key: string]: string }): void + send(staticResponse: StaticResponse): void + /** + * Continue the HTTP response to the browser, including any modifications made to `res`. + */ + send(): void + /** + * Wait for `delayMs` milliseconds before sending the response to the client. + */ + delay: (delayMs: number) => IncomingHttpResponse + /** + * Serve the response at `throttleKbps` kilobytes per second. + */ + throttle: (throttleKbps: number) => IncomingHttpResponse + } + + export type IncomingRequest = BaseMessage & { + responseTimeout?: number + } + + export interface IncomingHttpRequest extends IncomingRequest { + destroy(): void + reply(interceptor?: StaticResponse | HttpResponseInterceptor): void + reply(body: string | object, headers?: { [key: string]: string }): void + reply(status: number, body?: string | object, headers?: { [key: string]: string }): void + redirect(location: string, statusCode: number): void + } +} + +export interface DictMatcher { + [key: string]: T +} + +/** + * Matches a string using glob (`*`) matching. + */ +export type GlobPattern = string + +export type HttpRequestInterceptor = (req: CyHttpMessages.IncomingHttpRequest) => void | Promise + +export type HttpResponseInterceptor = (res: CyHttpMessages.IncomingHttpResponse, send?: () => void) => void | Promise + +/** + * Matches a single number or any of an array of acceptable numbers. + */ +export type NumberMatcher = number | number[] + +/** + * Request/response cycle. + */ +export interface Request { + id: string + /* @internal */ + log: any + request: CyHttpMessages.IncomingRequest + /** + * Was `cy.wait()` used to wait on this request? + * @internal + */ + requestWaited: boolean + response?: CyHttpMessages.IncomingResponse + /* @internal */ + responseHandler?: HttpResponseInterceptor + /** + * Was `cy.wait()` used to wait on the response to this request? + * @internal + */ + responseWaited: boolean + /* @internal */ + state: RequestState +} + +export type RequestState = + 'Received' | + 'Intercepted' | + 'ResponseReceived' | + 'ResponseIntercepted' | + 'Complete' | + 'Errored' + +export interface Route { + alias?: string + log: any + options: RouteMatcherOptions + handler: RouteHandler + hitCount: number + requests: { [key: string]: Request } +} + +export interface RouteMap { [key: string]: Route } + +/** + * A `RouteMatcher` describes a filter for HTTP requests. + */ +export type RouteMatcher = StringMatcher | RouteMatcherOptions + +export interface RouteMatcherCompatOptions { + response?: string | object +} + +export type RouteMatcherOptions = RouteMatcherOptionsGeneric + +export interface RouteMatcherOptionsGeneric extends RouteMatcherCompatOptions { + /** + * Match HTTP basic authentication. + */ + auth?: { username: S, password: S } + /** + * Match client request headers. + */ + headers?: DictMatcher + /** + * Match based on requested hostname. + */ + hostname?: S + /** + * Match requests served via HTTPS only. + */ + https?: boolean + /** + * @default 'GET' + */ + method?: S + /** + * Match on request path after the hostname, including query params. + */ + path?: S + /** + * Matches like `path`, but without query params. + */ + pathname?: S + /** + * Match based on requested port. + */ + port?: NumberMatcher + /** + * Match on parsed querystring parameters. + */ + query?: DictMatcher + /** + * Match based on full request URL. + */ + url?: S +} + +export type RouteHandlerController = HttpRequestInterceptor + +export type RouteHandler = string | StaticResponse | RouteHandlerController | object + +/** + * Describes a response that will be sent back to the browser to fulfill the request. + */ +export type StaticResponse = GenericStaticResponse + +export interface GenericStaticResponse { + /** + * If set, serve a fixture as the response body. + */ + fixture?: Fixture + /** + * If set, serve a static string/JSON object as the response body. + */ + body?: Body + /** + * @default {} + */ + headers?: { [key: string]: string } + /** + * @default 200 + */ + statusCode?: number + /** + * If `forceNetworkError` is truthy, Cypress will destroy the connection to the browser and send no response. Useful for simulating a server that is not reachable. Must not be set in combination with other options. + */ + forceNetworkError?: boolean +} + +/** + * Either a `GlobPattern` string or a `RegExp`. + */ +export type StringMatcher = GlobPattern | RegExp + +declare global { + namespace Cypress { + interface Chainable { + route2(url: RouteMatcher, response?: RouteHandler): Chainable + route2(method: string, url: RouteMatcher, response?: RouteHandler): Chainable + } + } +} diff --git a/packages/net-stubbing/lib/internal-types.ts b/packages/net-stubbing/lib/internal-types.ts new file mode 100644 index 000000000000..98a4c282f05e --- /dev/null +++ b/packages/net-stubbing/lib/internal-types.ts @@ -0,0 +1,98 @@ +import * as _ from 'lodash' +import { + RouteMatcherOptionsGeneric, + CyHttpMessages, + GenericStaticResponse, +} from './external-types' + +export type FixtureOpts = { + encoding: string + filePath: string +} + +export type BackendStaticResponse = GenericStaticResponse + +export const SERIALIZABLE_REQ_PROPS = [ + 'headers', + 'body', // doesn't exist on the OG message, but will be attached by the backend + 'url', + 'method', + 'httpVersion', + 'responseTimeout', +] + +export const SERIALIZABLE_RES_PROPS = _.concat( + SERIALIZABLE_REQ_PROPS, + 'statusCode', + 'statusMessage', +) + +export const DICT_STRING_MATCHER_FIELDS = ['headers', 'query'] + +export const STRING_MATCHER_FIELDS = ['auth.username', 'auth.password', 'hostname', 'method', 'path', 'pathname', 'url'] + +/** + * Serializable `StringMatcher` type. + */ +export interface AnnotatedStringMatcher { + type: 'regex' | 'glob' + value: string +} + +/** + * Serializable `RouteMatcherOptions` type. + */ +export type AnnotatedRouteMatcherOptions = RouteMatcherOptionsGeneric + +/** Types for messages between driver and server */ + +export declare namespace NetEventFrames { + export interface AddRoute { + routeMatcher: AnnotatedRouteMatcherOptions + staticResponse?: BackendStaticResponse + hasInterceptor: boolean + handlerId?: string + } + + interface BaseHttp { + requestId: string + routeHandlerId: string + } + + // fired when HTTP proxy receives headers + body of request + export interface HttpRequestReceived extends BaseHttp { + req: CyHttpMessages.IncomingRequest + /** + * Is the proxy expecting the driver to send `HttpRequestContinue`? + */ + notificationOnly: boolean + } + + // fired when driver is done modifying request and wishes to pass control back to the proxy + export interface HttpRequestContinue extends BaseHttp { + req: CyHttpMessages.IncomingRequest + staticResponse?: BackendStaticResponse + hasResponseHandler?: boolean + tryNextRoute?: boolean + } + + // fired when a response is received and the driver has a req.reply callback registered + export interface HttpResponseReceived extends BaseHttp { + res: CyHttpMessages.IncomingResponse + } + + // fired when driver is done modifying response or driver callback completes, + // passes control back to proxy + export interface HttpResponseContinue extends BaseHttp { + res?: CyHttpMessages.IncomingResponse + staticResponse?: BackendStaticResponse + // Millisecond timestamp for when the response should continue + continueResponseAt?: number + throttleKbps?: number + } + + // fired when a response has been sent completely by the server to an intercepted request + export interface HttpRequestComplete extends BaseHttp { + error?: Error & { code?: string } + } +} diff --git a/packages/net-stubbing/lib/server/driver-events.ts b/packages/net-stubbing/lib/server/driver-events.ts new file mode 100644 index 000000000000..91662ce4cc4d --- /dev/null +++ b/packages/net-stubbing/lib/server/driver-events.ts @@ -0,0 +1,96 @@ +import _ from 'lodash' +import Debug from 'debug' +import { + NetStubbingState, + GetFixtureFn, + BackendRoute, +} from './types' +import { + AnnotatedRouteMatcherOptions, + NetEventFrames, + RouteMatcherOptions, +} from '../types' +import { + getAllStringMatcherFields, + setBodyFromFixture, +} from './util' +import { onRequestContinue } from './intercept-request' +import { onResponseContinue } from './intercept-response' +import CyServer from '@packages/server' + +const debug = Debug('cypress:net-stubbing:server:driver-events') + +async function _onRouteAdded (state: NetStubbingState, getFixture: GetFixtureFn, options: NetEventFrames.AddRoute) { + const routeMatcher = _restoreMatcherOptionsTypes(options.routeMatcher) + const { staticResponse } = options + + if (staticResponse) { + await setBodyFromFixture(getFixture, staticResponse) + } + + const route: BackendRoute = { + routeMatcher, + getFixture, + ..._.omit(options, 'routeMatcher'), // skip the user's un-annotated routeMatcher + } + + state.routes.push(route) +} + +export function _restoreMatcherOptionsTypes (options: AnnotatedRouteMatcherOptions) { + const stringMatcherFields = getAllStringMatcherFields(options) + + const ret: RouteMatcherOptions = {} + + stringMatcherFields.forEach((field) => { + const obj = _.get(options, field) + + if (!obj) { + return + } + + let { value, type } = obj + + if (type === 'regex') { + const lastSlashI = value.lastIndexOf('/') + const flags = value.slice(lastSlashI + 1) + const pattern = value.slice(1, lastSlashI) + + value = new RegExp(pattern, flags) + } + + _.set(ret, field, value) + }) + + const noAnnotationRequiredFields = ['https', 'port', 'webSocket'] + + _.extend(ret, _.pick(options, noAnnotationRequiredFields)) + + return ret +} + +type OnNetEventOpts = { + eventName: string + state: NetStubbingState + socket: CyServer.Socket + getFixture: GetFixtureFn + args: any[] + frame: NetEventFrames.AddRoute | NetEventFrames.HttpRequestContinue | NetEventFrames.HttpResponseContinue +} + +export async function onNetEvent (opts: OnNetEventOpts): Promise { + const { state, socket, getFixture, args, eventName, frame } = opts + + debug('received driver event %o', { eventName, args }) + + switch (eventName) { + case 'route:added': + return _onRouteAdded(state, getFixture, frame) + case 'http:request:continue': + return onRequestContinue(state, frame, socket) + case 'http:response:continue': + return onResponseContinue(state, frame) + default: + throw new Error(`Unrecognized net event: ${eventName}`) + } +} diff --git a/packages/net-stubbing/lib/server/index.ts b/packages/net-stubbing/lib/server/index.ts new file mode 100644 index 000000000000..ec0f054dc8f0 --- /dev/null +++ b/packages/net-stubbing/lib/server/index.ts @@ -0,0 +1,13 @@ +export { onNetEvent } from './driver-events' + +export { InterceptError } from './intercept-error' + +export { InterceptRequest } from './intercept-request' + +export { InterceptResponse } from './intercept-response' + +export { NetStubbingState } from './types' + +import { state } from './state' + +export const netStubbingState = state diff --git a/packages/net-stubbing/lib/server/intercept-error.ts b/packages/net-stubbing/lib/server/intercept-error.ts new file mode 100644 index 000000000000..c06dd46cb7b6 --- /dev/null +++ b/packages/net-stubbing/lib/server/intercept-error.ts @@ -0,0 +1,33 @@ +import Debug from 'debug' + +import { ErrorMiddleware } from '@packages/proxy' +import { NetEventFrames } from '../types' +import { emit } from './util' +import errors from '@packages/server/lib/errors' + +const debug = Debug('cypress:net-stubbing:server:intercept-error') + +export const InterceptError: ErrorMiddleware = function () { + const backendRequest = this.netStubbingState.requests[this.req.requestId] + + if (!backendRequest) { + // either the original request was not intercepted, or there's nothing for the driver to do with this response + return this.next() + } + + debug('intercepting error %o', { req: this.req, backendRequest }) + + // this may get set back to `true` by another route + backendRequest.sendResponseToDriver = false + backendRequest.continueResponse = this.next + + const frame: NetEventFrames.HttpRequestComplete = { + routeHandlerId: backendRequest.route.handlerId!, + requestId: backendRequest.requestId, + error: errors.clone(this.error), + } + + emit(this.socket, 'http:request:complete', frame) + + this.next() +} diff --git a/packages/net-stubbing/lib/server/intercept-request.ts b/packages/net-stubbing/lib/server/intercept-request.ts new file mode 100644 index 000000000000..bb1950bcd74c --- /dev/null +++ b/packages/net-stubbing/lib/server/intercept-request.ts @@ -0,0 +1,277 @@ +import _ from 'lodash' +import concatStream from 'concat-stream' +import Debug from 'debug' +import minimatch from 'minimatch' +import url from 'url' + +import { + CypressIncomingRequest, + RequestMiddleware, +} from '@packages/proxy' +import { + BackendRoute, + BackendRequest, + NetStubbingState, +} from './types' +import { + RouteMatcherOptions, + CyHttpMessages, + NetEventFrames, + SERIALIZABLE_REQ_PROPS, +} from '../types' +import { getAllStringMatcherFields, sendStaticResponse, emit, setBodyFromFixture } from './util' +import CyServer from '@packages/server' + +const debug = Debug('cypress:net-stubbing:server:intercept-request') + +/** + * Returns `true` if `req` matches all supplied properties on `routeMatcher`, `false` otherwise. + */ +// TOOD: optimize to short-circuit on route not match +export function _doesRouteMatch (routeMatcher: RouteMatcherOptions, req: CypressIncomingRequest) { + const matchable = _getMatchableForRequest(req) + + let match = true + + // get a list of all the fields which exist where a rule needs to be succeed + const stringMatcherFields = getAllStringMatcherFields(routeMatcher) + const booleanFields = _.filter(_.keys(routeMatcher), _.partial(_.includes, ['https', 'webSocket'])) + const numberFields = _.filter(_.keys(routeMatcher), _.partial(_.includes, ['port'])) + + stringMatcherFields.forEach((field) => { + const matcher = _.get(routeMatcher, field) + let value = _.get(matchable, field, '') + + if (typeof value !== 'string') { + value = String(value) + } + + if (matcher.test) { + // value is a regex + match = match && matcher.test(value) + + return + } + + if (field === 'url') { + // for urls, check that it appears anywhere in the string + if (value.includes(matcher)) { + return + } + } + + match = match && minimatch(value, matcher, { matchBase: true }) + }) + + booleanFields.forEach((field) => { + const matcher = _.get(routeMatcher, field) + const value = _.get(matchable, field) + + match = match && (matcher === value) + }) + + numberFields.forEach((field) => { + const matcher = _.get(routeMatcher, field) + const value = _.get(matchable, field) + + if (matcher.length) { + // list of numbers, any one can match + match = match && matcher.includes(value) + + return + } + + match = match && (matcher === value) + }) + + return match +} + +export function _getMatchableForRequest (req: CypressIncomingRequest) { + let matchable: any = _.pick(req, ['headers', 'method', 'webSocket']) + + const authorization = req.headers['authorization'] + + if (authorization) { + const [mechanism, credentials] = authorization.split(' ', 2) + + if (mechanism && credentials && mechanism.toLowerCase() === 'basic') { + const [username, password] = Buffer.from(credentials, 'base64').toString().split(':', 2) + + matchable.auth = { username, password } + } + } + + const proxiedUrl = url.parse(req.proxiedUrl, true) + + _.assign(matchable, _.pick(proxiedUrl, ['hostname', 'path', 'pathname', 'port', 'query'])) + + matchable.url = req.proxiedUrl + + matchable.https = proxiedUrl.protocol && (proxiedUrl.protocol.indexOf('https') === 0) + + if (!matchable.port) { + matchable.port = matchable.https ? 443 : 80 + } + + return matchable +} + +function _getRouteForRequest (routes: BackendRoute[], req: CypressIncomingRequest, prevRoute?: BackendRoute) { + const possibleRoutes = prevRoute ? routes.slice(_.findIndex(routes, prevRoute) + 1) : routes + + return _.find(possibleRoutes, (route) => { + return _doesRouteMatch(route.routeMatcher, req) + }) +} + +/** + * Called when a new request is received in the proxy layer. + * @param project + * @param req + * @param res + * @param cb Can be called to resume the proxy's normal behavior. If `res` is not handled and this is not called, the request will hang. + */ +export const InterceptRequest: RequestMiddleware = function () { + const route = _getRouteForRequest(this.netStubbingState.routes, this.req) + + if (!route) { + // not intercepted, carry on normally... + return this.next() + } + + const requestId = _.uniqueId('interceptedRequest') + + debug('intercepting request %o', { requestId, route, req: _.pick(this.req, 'url') }) + + const request: BackendRequest = { + requestId, + route, + continueRequest: this.next, + onResponse: this.onResponse, + req: this.req, + res: this.res, + } + + // attach requestId to the original req object for later use + this.req.requestId = requestId + + this.netStubbingState.requests[requestId] = request + + _interceptRequest(this.netStubbingState, request, route, this.socket) +} + +function _interceptRequest (state: NetStubbingState, request: BackendRequest, route: BackendRoute, socket: CyServer.Socket) { + const notificationOnly = !route.hasInterceptor + + const frame: NetEventFrames.HttpRequestReceived = { + routeHandlerId: route.handlerId!, + requestId: request.req.requestId, + req: _.extend(_.pick(request.req, SERIALIZABLE_REQ_PROPS), { + url: request.req.proxiedUrl, + }) as CyHttpMessages.IncomingRequest, + notificationOnly, + } + + request.res.once('finish', () => { + emit(socket, 'http:request:complete', { + requestId: request.requestId, + routeHandlerId: route.handlerId!, + }) + + debug('request/response finished, cleaning up %o', { requestId: request.requestId }) + delete state.requests[request.requestId] + }) + + const emitReceived = () => { + emit(socket, 'http:request:received', frame) + } + + if (route.staticResponse) { + emitReceived() + + return sendStaticResponse(request.res, route.staticResponse, request.onResponse!) + } + + if (notificationOnly) { + emitReceived() + + const nextRoute = getNextRoute(state, request.req, frame.routeHandlerId) + + if (!nextRoute) { + return request.continueRequest() + } + + return _interceptRequest(state, request, nextRoute, socket) + } + + // if we already have a body, just emit + if (frame.req.body) { + return emitReceived() + } + + // else, buffer the body + request.req.pipe(concatStream((reqBody) => { + frame.req.body = reqBody.toString() + emitReceived() + })) +} + +/** + * If applicable, return the route that is next in line after `prevRouteHandlerId` to handle `req`. + */ +function getNextRoute (state: NetStubbingState, req: CypressIncomingRequest, prevRouteHandlerId: string): BackendRoute | undefined { + const prevRoute = _.find(state.routes, { handlerId: prevRouteHandlerId }) + + if (!prevRoute) { + return + } + + return _getRouteForRequest(state.routes, req, prevRoute) +} + +export async function onRequestContinue (state: NetStubbingState, frame: NetEventFrames.HttpRequestContinue, socket: CyServer.Socket) { + const backendRequest = state.requests[frame.requestId] + + if (!backendRequest) { + debug('onRequestContinue received but no backendRequest exists %o', { frame }) + + return + } + + frame.req.url = url.resolve(backendRequest.req.proxiedUrl, frame.req.url) + + // modify the original paused request object using what the client returned + _.assign(backendRequest.req, _.pick(frame.req, SERIALIZABLE_REQ_PROPS)) + + // proxiedUrl is used to initialize the new request + backendRequest.req.proxiedUrl = frame.req.url + + // update problematic headers + // update content-length if available + if (backendRequest.req.headers['content-length'] && frame.req.body) { + backendRequest.req.headers['content-length'] = frame.req.body.length + } + + if (frame.hasResponseHandler) { + backendRequest.sendResponseToDriver = true + } + + if (frame.tryNextRoute) { + const nextRoute = getNextRoute(state, backendRequest.req, frame.routeHandlerId) + + if (!nextRoute) { + return backendRequest.continueRequest() + } + + return _interceptRequest(state, backendRequest, nextRoute, socket) + } + + if (frame.staticResponse) { + await setBodyFromFixture(backendRequest.route.getFixture, frame.staticResponse) + + return sendStaticResponse(backendRequest.res, frame.staticResponse, backendRequest.onResponse!) + } + + backendRequest.continueRequest() +} diff --git a/packages/net-stubbing/lib/server/intercept-response.ts b/packages/net-stubbing/lib/server/intercept-response.ts new file mode 100644 index 000000000000..7c427be0a064 --- /dev/null +++ b/packages/net-stubbing/lib/server/intercept-response.ts @@ -0,0 +1,138 @@ +import _ from 'lodash' +import concatStream from 'concat-stream' +import Debug from 'debug' +import { PassThrough, Readable } from 'stream' +import ThrottleStream from 'throttle' + +import { + ResponseMiddleware, +} from '@packages/proxy' +import { + NetStubbingState, +} from './types' +import { + CyHttpMessages, + NetEventFrames, + SERIALIZABLE_RES_PROPS, +} from '../types' +import { + emit, + sendStaticResponse, + setBodyFromFixture, +} from './util' + +const debug = Debug('cypress:net-stubbing:server:intercept-response') + +export const InterceptResponse: ResponseMiddleware = function () { + const backendRequest = this.netStubbingState.requests[this.req.requestId] + + debug('InterceptResponse %o', { req: _.pick(this.req, 'url'), backendRequest }) + + if (!backendRequest || !backendRequest.sendResponseToDriver) { + // either the original request was not intercepted, or there's nothing for the driver to do with this response + return this.next() + } + + // this may get set back to `true` by another route + backendRequest.sendResponseToDriver = false + + backendRequest.incomingRes = this.incomingRes + + backendRequest.onResponse = (incomingRes, resStream) => { + this.incomingRes = incomingRes + + backendRequest.continueResponse!(resStream) + } + + backendRequest.continueResponse = (newResStream?: Readable) => { + if (newResStream) { + this.incomingResStream = newResStream.on('error', this.onError) + } + + this.next() + } + + const frame: NetEventFrames.HttpResponseReceived = { + routeHandlerId: backendRequest.route.handlerId!, + requestId: backendRequest.requestId, + res: _.extend(_.pick(this.incomingRes, SERIALIZABLE_RES_PROPS), { + url: this.req.proxiedUrl, + }) as CyHttpMessages.IncomingResponse, + } + + const res = frame.res as CyHttpMessages.IncomingResponse + + const emitReceived = () => { + emit(this.socket, 'http:response:received', frame) + } + + this.makeResStreamPlainText() + + this.incomingResStream.pipe(concatStream((resBody) => { + res.body = resBody.toString() + emitReceived() + })) +} + +export function onResponseContinue (state: NetStubbingState, frame: NetEventFrames.HttpResponseContinue) { + const backendRequest = state.requests[frame.requestId] + + if (typeof backendRequest === 'undefined') { + return + } + + const { res } = backendRequest + + debug('_onResponseContinue %o', { backendRequest: _.omit(backendRequest, 'res.body'), frame: _.omit(frame, 'res.body') }) + + async function continueResponse () { + let newResStream: Readable + + function throttleify (body) { + const throttleStr = new ThrottleStream(frame.throttleKbps! * 1024) + + throttleStr.write(body) + throttleStr.end() + + return throttleStr + } + + if (frame.staticResponse) { + await setBodyFromFixture(backendRequest.route.getFixture, frame.staticResponse) + const bodyStream = frame.throttleKbps ? throttleify(frame.staticResponse.body) : undefined + + return sendStaticResponse(res, frame.staticResponse, backendRequest.onResponse!, bodyStream) + } + + // merge the changed response attributes with our response and continue + _.assign(res, _.pick(frame.res, SERIALIZABLE_RES_PROPS)) + + function sendBody (bodyBuffer) { + // transform the body string into stream format + if (frame.throttleKbps) { + newResStream = throttleify(bodyBuffer) + } else { + const pt = new PassThrough() + + pt.write(bodyBuffer) + pt.end() + + newResStream = pt + } + + backendRequest.continueResponse!(newResStream) + } + + return sendBody(res.body) + } + + if (typeof frame.continueResponseAt === 'number') { + const delayMs = frame.continueResponseAt - Date.now() + + if (delayMs > 0) { + return setTimeout(continueResponse, delayMs) + } + } + + return continueResponse() +} diff --git a/packages/net-stubbing/lib/server/state.ts b/packages/net-stubbing/lib/server/state.ts new file mode 100644 index 000000000000..b5a6eefd0012 --- /dev/null +++ b/packages/net-stubbing/lib/server/state.ts @@ -0,0 +1,23 @@ +import { noop } from 'lodash' +import { NetStubbingState } from './types' + +export function state (): NetStubbingState { + return { + requests: {}, + routes: [], + reset () { + // clean up requests that are still pending + for (const requestId in this.requests) { + const { res } = this.requests[requestId] + + res.removeAllListeners('finish') + res.removeAllListeners('error') + res.on('error', noop) + res.destroy() + } + + this.requests = {} + this.routes = [] + }, + } +} diff --git a/packages/net-stubbing/lib/server/types.ts b/packages/net-stubbing/lib/server/types.ts new file mode 100644 index 000000000000..46c88481495c --- /dev/null +++ b/packages/net-stubbing/lib/server/types.ts @@ -0,0 +1,53 @@ +import { IncomingMessage } from 'http' +import { Readable } from 'stream' +import { + CypressIncomingRequest, + CypressOutgoingResponse, +} from '@packages/proxy' +import { + RouteMatcherOptions, + BackendStaticResponse, +} from '../types' + +export type GetFixtureFn = (path: string, opts?: { encoding?: string }) => Promise + +export interface BackendRoute { + routeMatcher: RouteMatcherOptions + handlerId?: string + hasInterceptor: boolean + staticResponse?: BackendStaticResponse + getFixture: GetFixtureFn +} + +export interface BackendRequest { + requestId: string + /** + * The route that matched this request. + */ + route: BackendRoute + /** + * A callback that can be used to make the request go outbound. + */ + continueRequest: Function + /** + * A callback that can be used to send the response through the proxy. + */ + continueResponse?: (newResStream?: Readable) => void + onResponse?: (incomingRes: IncomingMessage, resStream: Readable) => void + req: CypressIncomingRequest + res: CypressOutgoingResponse + incomingRes?: IncomingMessage + /** + * Should the response go to the driver, or should it be allowed to continue? + */ + sendResponseToDriver?: boolean +} + +export interface NetStubbingState { + // map of request IDs to requests in flight + requests: { + [requestId: string]: BackendRequest + } + routes: BackendRoute[] + reset: () => void +} diff --git a/packages/net-stubbing/lib/server/util.ts b/packages/net-stubbing/lib/server/util.ts new file mode 100644 index 000000000000..f67f62e61535 --- /dev/null +++ b/packages/net-stubbing/lib/server/util.ts @@ -0,0 +1,151 @@ +import _ from 'lodash' +import Debug from 'debug' +import isHtml from 'is-html' +import { ServerResponse, IncomingMessage } from 'http' +import { + RouteMatcherOptionsGeneric, + STRING_MATCHER_FIELDS, + DICT_STRING_MATCHER_FIELDS, + BackendStaticResponse, +} from '../types' +import { Readable, PassThrough } from 'stream' +import CyServer from '@packages/server' +import { Socket } from 'net' +import { GetFixtureFn } from './types' + +// TODO: move this into net-stubbing once cy.route is removed +import { parseContentType } from '@packages/server/lib/controllers/xhrs' + +const debug = Debug('cypress:net-stubbing:server:util') + +export function emit (socket: CyServer.Socket, eventName: string, data: object) { + debug('sending event to driver %o', { eventName, data }) + socket.toDriver('net:event', eventName, data) +} + +export function getAllStringMatcherFields (options: RouteMatcherOptionsGeneric) { + return _.concat( + _.filter(STRING_MATCHER_FIELDS, _.partial(_.has, options)), + // add the nested DictStringMatcher values to the list of fields + _.flatten( + _.filter( + DICT_STRING_MATCHER_FIELDS.map((field) => { + const value = options[field] + + if (value) { + return _.keys(value).map((key) => { + return `${field}.${key}` + }) + } + + return '' + }), + ), + ), + ) +} + +/** + * Generate a "response object" that looks like a real Node HTTP response. + * Instead of directly manipulating the response by using `res.status`, `res.setHeader`, etc., + * generating an IncomingMessage allows us to treat the response the same as any other "real" + * HTTP response, which means the proxy layer can apply response middleware to it. + */ +function _getFakeClientResponse (opts: { + statusCode: number + headers: { + [k: string]: string + } + body: string +}) { + const clientResponse = new IncomingMessage(new Socket) + + // be nice and infer this content-type for the user + if (!caseInsensitiveGet(opts.headers || {}, 'content-type') && isHtml(opts.body)) { + opts.headers['content-type'] = 'text/html' + } + + _.merge(clientResponse, opts) + + return clientResponse +} + +const caseInsensitiveGet = function (obj, lowercaseProperty) { + for (let key of Object.keys(obj)) { + if (key.toLowerCase() === lowercaseProperty) { + return obj[key] + } + } +} + +export async function setBodyFromFixture (getFixtureFn: GetFixtureFn, staticResponse: BackendStaticResponse) { + const { fixture } = staticResponse + + if (!fixture) { + return + } + + const data = await getFixtureFn(fixture.filePath, { encoding: fixture.encoding }) + + const { headers } = staticResponse + + if (!headers || !caseInsensitiveGet(headers, 'content-type')) { + _.set(staticResponse, 'headers.content-type', parseContentType(data)) + } + + function getBody (): string { + // NOTE: for backwards compatibility with cy.route + if (data === null) { + return '' + } + + if (!_.isBuffer(data) && !_.isString(data)) { + // TODO: probably we can use another function in fixtures.js that doesn't require us to remassage the fixture + return JSON.stringify(data) + } + + return data + } + + staticResponse.body = getBody() +} + +/** + * Using an existing response object, send a response shaped by a StaticResponse object. + * @param res Response object. + * @param staticResponse BackendStaticResponse object. + * @param onResponse Will be called with the response metadata + body stream + * @param resStream Optionally, provide a Readable stream to be used as the response body (overrides staticResponse.body) + */ +export function sendStaticResponse (res: ServerResponse, staticResponse: BackendStaticResponse, onResponse: (incomingRes: IncomingMessage, stream: Readable) => void, resStream?: Readable) { + if (staticResponse.forceNetworkError) { + res.connection.destroy() + res.destroy() + + return + } + + const statusCode = staticResponse.statusCode || 200 + const headers = staticResponse.headers || {} + const body = resStream ? '' : staticResponse.body || '' + + const incomingRes = _getFakeClientResponse({ + statusCode, + headers, + body, + }) + + if (!resStream) { + const pt = new PassThrough() + + if (staticResponse.body) { + pt.write(staticResponse.body) + } + + pt.end() + + resStream = pt + } + + onResponse(incomingRes, resStream) +} diff --git a/packages/net-stubbing/lib/types.ts b/packages/net-stubbing/lib/types.ts new file mode 100644 index 000000000000..b663dfbafd6a --- /dev/null +++ b/packages/net-stubbing/lib/types.ts @@ -0,0 +1,3 @@ +export * from './external-types' + +export * from './internal-types' diff --git a/packages/net-stubbing/package.json b/packages/net-stubbing/package.json new file mode 100644 index 000000000000..b0b81f0c6c01 --- /dev/null +++ b/packages/net-stubbing/package.json @@ -0,0 +1,27 @@ +{ + "name": "@packages/net-stubbing", + "version": "0.0.0", + "private": true, + "main": "./lib/server", + "scripts": { + "build-prod": "tsc --project .", + "clean-deps": "rm -rf node_modules", + "test": "mocha -r @packages/ts/register --reporter mocha-multi-reporters --reporter-options configFile=../../mocha-reporter-config.json --exit test/unit/*" + }, + "dependencies": { + "concat-stream": "^2.0.0", + "is-html": "^2.0.0", + "lodash": "4.17.15", + "minimatch": "^3.0.4", + "throttle": "^1.0.3" + }, + "devDependencies": { + "@types/mocha": "7.0.2", + "bin-up": "1.2.0", + "chai": "4.2.0", + "mocha": "7.1.2" + }, + "files": [ + "lib" + ] +} diff --git a/packages/net-stubbing/test/.eslintrc.json b/packages/net-stubbing/test/.eslintrc.json new file mode 100644 index 000000000000..b5ed5206d083 --- /dev/null +++ b/packages/net-stubbing/test/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "extends": [ + "plugin:@cypress/dev/tests" + ] +} diff --git a/packages/net-stubbing/test/unit/driver-events-spec.ts b/packages/net-stubbing/test/unit/driver-events-spec.ts new file mode 100644 index 000000000000..d4af96315ad7 --- /dev/null +++ b/packages/net-stubbing/test/unit/driver-events-spec.ts @@ -0,0 +1,23 @@ +import { + _restoreMatcherOptionsTypes, +} from '../../lib/server/driver-events' +import { expect } from 'chai' + +describe('driver events', function () { + context('._restoreMatcherOptionsTypes', function () { + it('rehydrates regexes properly', function () { + const { url } = _restoreMatcherOptionsTypes({ + url: { + type: 'regex', + value: '/aaa/igm', + }, + }) + + expect(url).to.be.instanceOf(RegExp) + .and.include({ + flags: 'gim', + source: 'aaa', + }) + }) + }) +}) diff --git a/packages/net-stubbing/test/unit/intercept-request-spec.ts b/packages/net-stubbing/test/unit/intercept-request-spec.ts new file mode 100644 index 000000000000..4190d294208c --- /dev/null +++ b/packages/net-stubbing/test/unit/intercept-request-spec.ts @@ -0,0 +1,140 @@ +import { + _doesRouteMatch, + _getMatchableForRequest, +} from '../../lib/server/intercept-request' +import { expect } from 'chai' +import { CypressIncomingRequest } from '@packages/proxy' + +describe('intercept-request', function () { + context('._getMatchableForRequest', function () { + it('converts a fully-fledged req into a matchable shape', function () { + const req = { + headers: { + authorization: 'basic Zm9vOmJhcg==', + host: 'google.com', + quuz: 'quux', + }, + method: 'GET', + proxiedUrl: 'https://google.com/asdf?1234=a', + } as unknown as CypressIncomingRequest + + const matchable = _getMatchableForRequest(req) + + expect(matchable).to.deep.eq({ + auth: { + username: 'foo', + password: 'bar', + }, + method: req.method, + headers: req.headers, + hostname: 'google.com', + path: '/asdf?1234=a', + pathname: '/asdf', + query: { + '1234': 'a', + }, + https: true, + port: 443, + url: 'https://google.com/asdf?1234=a', + }) + }) + }) + + context('._doesRouteMatch', function () { + it('matches on url as regexp', function () { + const req = { + headers: { + quuz: 'quux', + }, + method: 'GET', + proxiedUrl: 'https://google.com/foo', + } as unknown as CypressIncomingRequest + + const matched = _doesRouteMatch({ + url: /foo/, + }, req) + + expect(matched).to.be.true + }) + + it('matches on a null matcher', function () { + const req = { + headers: { + quuz: 'quux', + }, + method: 'GET', + proxiedUrl: 'https://google.com/asdf?1234=a', + } as unknown as CypressIncomingRequest + + const matched = _doesRouteMatch({}, req) + + expect(matched).to.be.true + }) + + it('matches on auth matcher', function () { + const req = { + headers: { + authorization: 'basic Zm9vOmJhcg==', + }, + method: 'GET', + proxiedUrl: 'https://google.com/asdf?1234=a', + } as unknown as CypressIncomingRequest + + const matched = _doesRouteMatch({ + auth: { + username: /^Fo[aob]$/i, + password: /.*/, + }, + }, req) + + expect(matched).to.be.true + }) + + it('doesn\'t match on a partial match', function () { + const req = { + headers: { + authorization: 'basic Zm9vOmJhcg==', + }, + method: 'GET', + proxiedUrl: 'https://google.com/asdf?1234=a', + } as unknown as CypressIncomingRequest + + const matched = _doesRouteMatch({ + auth: { + username: /^Fo[aob]$/i, + password: /.*/, + }, + method: 'POST', + }, req) + + expect(matched).to.be.false + }) + + it('handles querystrings as expected', function () { + const req = { + headers: {}, + method: 'GET', + proxiedUrl: '/abc?foo=bar&baz=quux', + } as unknown as CypressIncomingRequest + + expect(_doesRouteMatch({ + query: { + foo: 'b*r', + baz: /quu[x]/, + }, + }, req)).to.be.true + + expect(_doesRouteMatch({ + path: '/abc?foo=bar&baz=qu*x', + }, req)).to.be.true + + expect(_doesRouteMatch({ + pathname: '/abc', + }, req)).to.be.true + + expect(_doesRouteMatch({ + url: '*', + }, req)).to.be.true + }) + }) +}) diff --git a/packages/net-stubbing/tsconfig.json b/packages/net-stubbing/tsconfig.json new file mode 100644 index 000000000000..b8c4ca630a61 --- /dev/null +++ b/packages/net-stubbing/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../ts/tsconfig.json", + "include": [ + "*.ts", + "lib/*.ts", + "lib/**/*.ts" + ], + "files": [ + "./../ts/index.d.ts" + ] +} diff --git a/packages/proxy/lib/http/error-middleware.ts b/packages/proxy/lib/http/error-middleware.ts index c55f91b11ffc..78595ac3de95 100644 --- a/packages/proxy/lib/http/error-middleware.ts +++ b/packages/proxy/lib/http/error-middleware.ts @@ -1,7 +1,8 @@ import debugModule from 'debug' +import { HttpMiddleware } from '.' +import { InterceptError } from '@packages/net-stubbing' import { Readable } from 'stream' import { Request } from '@cypress/request' -import { HttpMiddleware } from './' const debug = debugModule('cypress:proxy:http:error-middleware') @@ -46,6 +47,7 @@ export const DestroyResponse: ErrorMiddleware = function () { export default { LogError, + InterceptError, AbortRequest, UnpipeResponse, DestroyResponse, diff --git a/packages/proxy/lib/http/index.ts b/packages/proxy/lib/http/index.ts index ae4c6561acb7..29da9d5f0ab3 100644 --- a/packages/proxy/lib/http/index.ts +++ b/packages/proxy/lib/http/index.ts @@ -1,8 +1,14 @@ import _ from 'lodash' +import CyServer from '@packages/server' +import { + CypressIncomingRequest, + CypressOutgoingResponse, +} from '@packages/proxy' import debugModule from 'debug' import ErrorMiddleware from './error-middleware' import { HttpBuffers } from './util/buffers' import { IncomingMessage } from 'http' +import { NetStubbingState } from '@packages/net-stubbing' import Bluebird from 'bluebird' import { Readable } from 'stream' import { Request, Response } from 'express' @@ -20,38 +26,36 @@ export enum HttpStages { export type HttpMiddleware = (this: HttpMiddlewareThis) => void -export type CypressRequest = Request & { - // TODO: what's this difference from req.url? is it only for non-proxied requests? - proxiedUrl: string - abort: () => void -} - -type MiddlewareStacks = { +export type HttpMiddlewareStacks = { [stage in HttpStages]: { [name: string]: HttpMiddleware } } -export type CypressResponse = Response & { - isInitial: null | boolean - wantsInjection: 'full' | 'partial' | false - wantsSecurityRemoved: null | boolean -} - type HttpMiddlewareCtx = { - req: CypressRequest - res: CypressResponse + req: CypressIncomingRequest + res: CypressOutgoingResponse - middleware: MiddlewareStacks + middleware: HttpMiddlewareStacks deferSourceMapRewrite: (opts: { js: string, url: string }) => string } & T +export type ServerCtx = Readonly<{ + config: CyServer.Config + getFileServerToken: () => string + getRemoteState: CyServer.getRemoteState + netStubbingState: NetStubbingState + middleware: HttpMiddlewareStacks + socket: CyServer.Socket + request: any +}> + const READONLY_MIDDLEWARE_KEYS: (keyof HttpMiddlewareThis<{}>)[] = [ 'buffers', 'config', 'getFileServerToken', 'getRemoteState', - 'request', + 'netStubbingState', 'next', 'end', 'onResponse', @@ -59,19 +63,15 @@ const READONLY_MIDDLEWARE_KEYS: (keyof HttpMiddlewareThis<{}>)[] = [ 'skipMiddleware', ] -type HttpMiddlewareThis = HttpMiddlewareCtx & Readonly<{ +type HttpMiddlewareThis = HttpMiddlewareCtx & ServerCtx & Readonly<{ buffers: HttpBuffers - config: any - getFileServerToken: () => string - getRemoteState: () => any - request: any next: () => void /** * Call to completely end the stage, bypassing any remaining middleware. */ end: () => void - onResponse: (incomingRes: Response, resStream: Readable) => void + onResponse: (incomingRes: IncomingMessage, resStream: Readable) => void onError: (error: Error) => void skipMiddleware: (name: string) => void }> @@ -134,7 +134,7 @@ export function _runStage (type: HttpStages, ctx: any) { _end(runMiddlewareStack()) }, end: () => _end(), - onResponse: (incomingRes: IncomingMessage, resStream: Readable) => { + onResponse: (incomingRes: Response, resStream: Readable) => { ctx.incomingRes = incomingRes ctx.incomingResStream = resStream @@ -175,26 +175,25 @@ export function _runStage (type: HttpStages, ctx: any) { export class Http { buffers: HttpBuffers + config: CyServer.Config deferredSourceMapCache: DeferredSourceMapCache - config: any getFileServerToken: () => string getRemoteState: () => any - middleware: MiddlewareStacks + middleware: HttpMiddlewareStacks + netStubbingState: NetStubbingState request: any + socket: CyServer.Socket - constructor (opts: { - config: any - getFileServerToken: () => string - getRemoteState: () => any - middleware?: MiddlewareStacks - request: any - }) { + constructor (opts: ServerCtx & { middleware?: HttpMiddlewareStacks }) { this.buffers = new HttpBuffers() this.deferredSourceMapCache = new DeferredSourceMapCache(opts.request) this.config = opts.config this.getFileServerToken = opts.getFileServerToken this.getRemoteState = opts.getRemoteState + this.middleware = opts.middleware + this.netStubbingState = opts.netStubbingState + this.socket = opts.socket this.request = opts.request if (typeof opts.middleware === 'undefined') { @@ -203,8 +202,6 @@ export class Http { [HttpStages.IncomingResponse]: ResponseMiddleware, [HttpStages.Error]: ErrorMiddleware, } - } else { - this.middleware = opts.middleware } } @@ -219,6 +216,8 @@ export class Http { getRemoteState: this.getRemoteState, request: this.request, middleware: _.cloneDeep(this.middleware), + netStubbingState: this.netStubbingState, + socket: this.socket, deferSourceMapRewrite: (opts) => { this.deferredSourceMapCache.defer({ resHeaders: ctx.incomingRes.headers, diff --git a/packages/proxy/lib/http/request-middleware.ts b/packages/proxy/lib/http/request-middleware.ts index f8ab37cb6c00..40633b20e833 100644 --- a/packages/proxy/lib/http/request-middleware.ts +++ b/packages/proxy/lib/http/request-middleware.ts @@ -1,6 +1,8 @@ import _ from 'lodash' -import debugModule from 'debug' +import CyServer from '@packages/server' import { blocked, cors } from '@packages/network' +import { InterceptRequest } from '@packages/net-stubbing' +import debugModule from 'debug' import { HttpMiddleware } from './' export type RequestMiddleware = HttpMiddleware<{ @@ -17,27 +19,23 @@ const LogRequest: RequestMiddleware = function () { this.next() } -const RedirectToClientRouteIfUnloaded: RequestMiddleware = function () { - // if we have an unload header it means our parent app has been navigated away - // directly and we need to automatically redirect to the clientRoute - if (this.req.cookies['__cypress.unload']) { - this.res.redirect(this.config.clientRoute) +const MaybeEndRequestWithBufferedResponse: RequestMiddleware = function () { + const buffer = this.buffers.take(this.req.proxiedUrl) - return this.end() + if (buffer) { + debug('got a buffer %o', _.pick(buffer, 'url')) + this.res.wantsInjection = 'full' + + return this.onResponse(buffer.response, buffer.stream) } this.next() } -// TODO: is this necessary? it seems to be for requesting Cypress w/o the proxy, -// which isn't currently supported -const RedirectToClientRouteIfNotProxied: RequestMiddleware = function () { - // when you access cypress from a browser which has not had its proxy setup then - // req.url will match req.proxiedUrl and we'll know to instantly redirect them - // to the correct client route - if (this.req.url === this.req.proxiedUrl && !this.getRemoteState().visiting) { - // if we dont have a remoteState.origin that means we're initially requesting - // the cypress app and we need to redirect to the root path that serves the app +const RedirectToClientRouteIfUnloaded: RequestMiddleware = function () { + // if we have an unload header it means our parent app has been navigated away + // directly and we need to automatically redirect to the clientRoute + if (this.req.cookies['__cypress.unload']) { this.res.redirect(this.config.clientRoute) return this.end() @@ -68,19 +66,6 @@ const EndRequestsToBlockedHosts: RequestMiddleware = function () { this.next() } -const MaybeEndRequestWithBufferedResponse: RequestMiddleware = function () { - const buffer = this.buffers.take(this.req.proxiedUrl) - - if (buffer) { - debug('got a buffer %o', _.pick(buffer, 'url')) - this.res.wantsInjection = 'full' - - return this.onResponse(buffer.response, buffer.stream) - } - - this.next() -} - const StripUnsupportedAcceptEncoding: RequestMiddleware = function () { // Cypress can only support plaintext or gzip, so make sure we don't request anything else const acceptEncoding = this.req.headers['accept-encoding'] @@ -96,7 +81,7 @@ const StripUnsupportedAcceptEncoding: RequestMiddleware = function () { this.next() } -function reqNeedsBasicAuthHeaders (req, { auth, origin }) { +function reqNeedsBasicAuthHeaders (req, { auth, origin }: CyServer.RemoteState) { //if we have auth headers, this request matches our origin, protection space, and the user has not supplied auth headers return auth && !req.headers['authorization'] && cors.urlMatchesOriginProtectionSpace(req.proxiedUrl, origin) } @@ -104,7 +89,7 @@ function reqNeedsBasicAuthHeaders (req, { auth, origin }) { const MaybeSetBasicAuthHeaders: RequestMiddleware = function () { const remoteState = this.getRemoteState() - if (reqNeedsBasicAuthHeaders(this.req, remoteState)) { + if (remoteState.auth && reqNeedsBasicAuthHeaders(this.req, remoteState)) { const { auth } = remoteState const base64 = Buffer.from(`${auth.username}:${auth.password}`).toString('base64') @@ -116,12 +101,15 @@ const MaybeSetBasicAuthHeaders: RequestMiddleware = function () { const SendRequestOutgoing: RequestMiddleware = function () { const requestOptions = { + timeout: this.req.responseTimeout, strictSSL: false, followRedirect: false, retryIntervals: [0, 100, 200, 200], url: this.req.proxiedUrl, } + const requestBodyBuffered = !!this.req.body + const { strategy, origin, fileServer } = this.getRemoteState() if (strategy === 'file' && requestOptions.url.startsWith(origin)) { @@ -130,6 +118,10 @@ const SendRequestOutgoing: RequestMiddleware = function () { requestOptions.url = requestOptions.url.replace(origin, fileServer) } + if (requestBodyBuffered) { + _.assign(requestOptions, _.pick(this.req, 'method', 'body', 'headers')) + } + const req = this.request.create(requestOptions) req.on('error', this.onError) @@ -139,18 +131,20 @@ const SendRequestOutgoing: RequestMiddleware = function () { req.abort() }) - // pipe incoming request body, headers to new request - this.req.pipe(req) + if (!requestBodyBuffered) { + // pipe incoming request body, headers to new request + this.req.pipe(req) + } this.outgoingReq = req } export default { LogRequest, + MaybeEndRequestWithBufferedResponse, + InterceptRequest, RedirectToClientRouteIfUnloaded, - RedirectToClientRouteIfNotProxied, EndRequestsToBlockedHosts, - MaybeEndRequestWithBufferedResponse, StripUnsupportedAcceptEncoding, MaybeSetBasicAuthHeaders, SendRequestOutgoing, diff --git a/packages/proxy/lib/http/response-middleware.ts b/packages/proxy/lib/http/response-middleware.ts index 08a74a153733..406e8db770cd 100644 --- a/packages/proxy/lib/http/response-middleware.ts +++ b/packages/proxy/lib/http/response-middleware.ts @@ -2,15 +2,25 @@ import _ from 'lodash' import charset from 'charset' import { CookieOptions } from 'express' import { cors, concatStream } from '@packages/network' -import { CypressRequest, CypressResponse, HttpMiddleware } from '.' +import { CypressIncomingRequest, CypressOutgoingResponse } from '@packages/proxy' import debugModule from 'debug' +import { HttpMiddleware } from '.' import iconv from 'iconv-lite' import { IncomingMessage, IncomingHttpHeaders } from 'http' +import { InterceptResponse } from '@packages/net-stubbing' import { PassThrough, Readable } from 'stream' import * as rewriter from './util/rewriter' import zlib from 'zlib' export type ResponseMiddleware = HttpMiddleware<{ + /** + * Before using `res.incomingResStream`, `prepareResStream` can be used + * to remove any encoding that prevents it from being returned as plain text. + * + * This is done as-needed to avoid unnecessary g(un)zipping. + */ + makeResStreamPlainText: () => void + isGunzipped: boolean incomingRes: IncomingMessage incomingResStream: Readable }> @@ -36,7 +46,7 @@ function getNodeCharsetFromResponse (headers: IncomingHttpHeaders, body: Buffer) return 'latin1' } -function reqMatchesOriginPolicy (req: CypressRequest, remoteState) { +function reqMatchesOriginPolicy (req: CypressIncomingRequest, remoteState) { if (remoteState.strategy === 'http') { return cors.urlMatchesOriginPolicyProps(req.proxiedUrl, remoteState.props) } @@ -48,7 +58,7 @@ function reqMatchesOriginPolicy (req: CypressRequest, remoteState) { return false } -function reqWillRenderHtml (req: CypressRequest) { +function reqWillRenderHtml (req: CypressIncomingRequest) { // will this request be rendered in the browser, necessitating injection? // https://github.com/cypress-io/cypress/issues/288 @@ -87,11 +97,11 @@ function resIsGzipped (res: IncomingMessage) { // HEAD, 1xx, 204, and 304 responses should never contain anything after headers const NO_BODY_STATUS_CODES = [204, 304] -function responseMustHaveEmptyBody (req: CypressRequest, res: IncomingMessage) { +function responseMustHaveEmptyBody (req: CypressIncomingRequest, res: IncomingMessage) { return _.some([_.includes(NO_BODY_STATUS_CODES, res.statusCode), _.invoke(req.method, 'toLowerCase') === 'head']) } -function setCookie (res: CypressResponse, k: string, v: string, domain: string) { +function setCookie (res: CypressOutgoingResponse, k: string, v: string, domain: string) { let opts: CookieOptions = { domain } if (!v) { @@ -103,7 +113,7 @@ function setCookie (res: CypressResponse, k: string, v: string, domain: string) return res.cookie(k, v, opts) } -function setInitialCookie (res: CypressResponse, remoteState: any, value) { +function setInitialCookie (res: CypressOutgoingResponse, remoteState: any, value) { // dont modify any cookies if we're trying to clear the initial cookie and we're not injecting anything // dont set the cookies if we're not on the initial request if ((!value && !res.wantsInjection) || !res.isInitial) { @@ -136,6 +146,24 @@ const LogResponse: ResponseMiddleware = function () { this.next() } +const AttachPlainTextStreamFn: ResponseMiddleware = function () { + this.makeResStreamPlainText = function () { + debug('ensuring resStream is plaintext') + + if (!this.isGunzipped && resIsGzipped(this.incomingRes)) { + debug('gunzipping response body') + + const gunzip = zlib.createGunzip(zlibOptions) + + this.incomingResStream = this.incomingResStream.pipe(gunzip).on('error', this.onError) + + this.isGunzipped = true + } + } + + this.next() +} + const PatchExpressSetHeader: ResponseMiddleware = function () { const { incomingRes } = this const originalSetHeader = this.res.setHeader @@ -345,20 +373,6 @@ const MaybeEndWithEmptyBody: ResponseMiddleware = function () { this.next() } -const MaybeGunzipBody: ResponseMiddleware = function () { - if (resIsGzipped(this.incomingRes) && (this.res.wantsInjection || this.res.wantsSecurityRemoved)) { - debug('ungzipping response body') - - const gunzip = zlib.createGunzip(zlibOptions) - - this.incomingResStream = this.incomingResStream.pipe(gunzip).on('error', this.onError) - } else { - this.skipMiddleware('GzipBody') // not needed anymore - } - - this.next() -} - const MaybeInjectHtml: ResponseMiddleware = function () { if (!this.res.wantsInjection) { return this.next() @@ -368,6 +382,8 @@ const MaybeInjectHtml: ResponseMiddleware = function () { debug('injecting into HTML') + this.makeResStreamPlainText() + this.incomingResStream.pipe(concatStream(async (body) => { const nodeCharset = getNodeCharsetFromResponse(this.incomingRes.headers, body) const decodedBody = iconv.decode(body, nodeCharset) @@ -399,6 +415,8 @@ const MaybeRemoveSecurity: ResponseMiddleware = function () { debug('removing JS framebusting code') + this.makeResStreamPlainText() + this.incomingResStream.setEncoding('utf8') this.incomingResStream = this.incomingResStream.pipe(rewriter.security({ isHtml: isHtml(this.incomingRes), @@ -411,8 +429,10 @@ const MaybeRemoveSecurity: ResponseMiddleware = function () { } const GzipBody: ResponseMiddleware = function () { - debug('regzipping response body') - this.incomingResStream = this.incomingResStream.pipe(zlib.createGzip(zlibOptions)).on('error', this.onError) + if (this.isGunzipped) { + debug('regzipping response body') + this.incomingResStream = this.incomingResStream.pipe(zlib.createGzip(zlibOptions)).on('error', this.onError) + } this.next() } @@ -424,6 +444,8 @@ const SendResponseBodyToClient: ResponseMiddleware = function () { export default { LogResponse, + AttachPlainTextStreamFn, + InterceptResponse, PatchExpressSetHeader, SetInjectionLevel, OmitProblematicHeaders, @@ -434,7 +456,6 @@ export default { CopyResponseStatusCode, ClearCyInitialCookie, MaybeEndWithEmptyBody, - MaybeGunzipBody, MaybeInjectHtml, MaybeRemoveSecurity, GzipBody, diff --git a/packages/proxy/lib/http/util/buffers.ts b/packages/proxy/lib/http/util/buffers.ts index 9c22b6d4e73b..02d806b5c61b 100644 --- a/packages/proxy/lib/http/util/buffers.ts +++ b/packages/proxy/lib/http/util/buffers.ts @@ -2,14 +2,14 @@ import _ from 'lodash' import debugModule from 'debug' import { uri } from '@packages/network' import { Readable } from 'stream' -import { Response } from 'express' +import { IncomingMessage } from 'http' const debug = debugModule('cypress:proxy:http:util:buffers') export type HttpBuffer = { details: object originalUrl: string - response: Response + response: IncomingMessage stream: Readable url: string } diff --git a/packages/proxy/lib/index.ts b/packages/proxy/lib/index.ts index 4c603b280632..07b47a87252b 100644 --- a/packages/proxy/lib/index.ts +++ b/packages/proxy/lib/index.ts @@ -1,7 +1,3 @@ export { NetworkProxy } from './network-proxy' -export { ErrorMiddleware } from './http/error-middleware' - -export { RequestMiddleware } from './http/request-middleware' - -export { ResponseMiddleware } from './http/response-middleware' +export * from './types' diff --git a/packages/proxy/lib/network-proxy.ts b/packages/proxy/lib/network-proxy.ts index 0f95c2b44aeb..cfbb82f124fc 100644 --- a/packages/proxy/lib/network-proxy.ts +++ b/packages/proxy/lib/network-proxy.ts @@ -1,15 +1,9 @@ -import { Http } from './http' +import { Http, ServerCtx } from './http' export class NetworkProxy { http: Http - constructor (opts: { - config: any - getRemoteState: () => any - getFileServerToken: () => string - middleware?: any - request: any - }) { + constructor (opts: ServerCtx) { this.http = new Http(opts) } diff --git a/packages/proxy/lib/types.ts b/packages/proxy/lib/types.ts new file mode 100644 index 000000000000..e0b3255cc2ba --- /dev/null +++ b/packages/proxy/lib/types.ts @@ -0,0 +1,29 @@ +import { Readable } from 'stream' +import { Request, Response } from 'express' + +/** + * An incoming request to the Cypress web server. + */ +export type CypressIncomingRequest = Request & { + proxiedUrl: string + abort: () => void + requestId: string + body?: string + responseTimeout?: number +} + +/** + * An outgoing response to an incoming request to the Cypress web server. + */ +export type CypressOutgoingResponse = Response & { + isInitial: null | boolean + wantsInjection: 'full' | 'partial' | false + wantsSecurityRemoved: null | boolean + body?: string | Readable +} + +export { ErrorMiddleware } from './http/error-middleware' + +export { RequestMiddleware } from './http/request-middleware' + +export { ResponseMiddleware } from './http/response-middleware' diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 72d8064dfcba..fad9f25392d9 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -7,7 +7,7 @@ "build-prod": "tsc --project .", "clean-deps": "rm -rf node_modules", "test": "yarn test-unit", - "test-unit": "mocha --reporter mocha-multi-reporters --reporter-options configFile=../../mocha-reporter-config.json" + "test-unit": "mocha -r @packages/ts/register -r test/pretest.ts --reporter mocha-multi-reporters --reporter-options configFile=../../mocha-reporter-config.json --exit test/unit/**/*" }, "dependencies": { "bluebird": "3.5.3", diff --git a/packages/proxy/test/mocha.opts b/packages/proxy/test/mocha.opts deleted file mode 100644 index 49a6697b5fc0..000000000000 --- a/packages/proxy/test/mocha.opts +++ /dev/null @@ -1,5 +0,0 @@ -test/unit ---compilers ts:@packages/ts/register ---timeout 10000 ---recursive ---require test/pretest.ts diff --git a/packages/proxy/test/unit/http/error-middleware.spec.ts b/packages/proxy/test/unit/http/error-middleware.spec.ts index e2baeca38c0e..ac195671d0e1 100644 --- a/packages/proxy/test/unit/http/error-middleware.spec.ts +++ b/packages/proxy/test/unit/http/error-middleware.spec.ts @@ -14,6 +14,7 @@ describe('http/error-middleware', function () { it('exports the members in the correct order', function () { expect(_.keys(ErrorMiddleware)).to.have.ordered.members([ 'LogError', + 'InterceptError', 'AbortRequest', 'UnpipeResponse', 'DestroyResponse', diff --git a/packages/proxy/test/unit/http/index.spec.ts b/packages/proxy/test/unit/http/index.spec.ts index e8e29d3988d2..4055144e398e 100644 --- a/packages/proxy/test/unit/http/index.spec.ts +++ b/packages/proxy/test/unit/http/index.spec.ts @@ -10,6 +10,7 @@ describe('http', function () { let incomingRequest let incomingResponse let error + let httpOpts beforeEach(function () { config = {} @@ -24,6 +25,8 @@ describe('http', function () { [HttpStages.IncomingResponse]: [incomingResponse], [HttpStages.Error]: [error], } + + httpOpts = { config, getRemoteState, middleware } }) it('calls IncomingRequest stack, then IncomingResponse stack', function () { @@ -43,11 +46,11 @@ describe('http', function () { this.end() }) - return new Http({ config, getRemoteState, middleware }) + return new Http(httpOpts) .handle({}, {}) .then(function () { - expect(incomingRequest).to.be.calledOnce - expect(incomingResponse).to.be.calledOnce + expect(incomingRequest, 'incomingRequest').to.be.calledOnce + expect(incomingResponse, 'incomingResponse').to.be.calledOnce expect(error).to.not.be.called }) }) @@ -60,7 +63,7 @@ describe('http', function () { this.end() }) - return new Http({ config, getRemoteState, middleware }) + return new Http(httpOpts) .handle({}, {}) .then(function () { expect(incomingRequest).to.be.calledOnce @@ -82,7 +85,7 @@ describe('http', function () { this.end() }) - return new Http({ config, getRemoteState, middleware }) + return new Http(httpOpts) .handle({}, {}) .then(function () { expect(incomingRequest).to.be.calledOnce @@ -144,7 +147,7 @@ describe('http', function () { middleware[HttpStages.IncomingResponse].push(incomingResponse2) middleware[HttpStages.Error].push(error2) - return new Http({ config, getRemoteState, middleware }) + return new Http(httpOpts) .handle({}, {}) .then(function () { [ diff --git a/packages/proxy/test/unit/http/request-middleware.spec.ts b/packages/proxy/test/unit/http/request-middleware.spec.ts index 07cd117c8754..3298c9479a03 100644 --- a/packages/proxy/test/unit/http/request-middleware.spec.ts +++ b/packages/proxy/test/unit/http/request-middleware.spec.ts @@ -6,10 +6,10 @@ describe('http/request-middleware', function () { it('exports the members in the correct order', function () { expect(_.keys(RequestMiddleware)).to.have.ordered.members([ 'LogRequest', + 'MaybeEndRequestWithBufferedResponse', + 'InterceptRequest', 'RedirectToClientRouteIfUnloaded', - 'RedirectToClientRouteIfNotProxied', 'EndRequestsToBlockedHosts', - 'MaybeEndRequestWithBufferedResponse', 'StripUnsupportedAcceptEncoding', 'MaybeSetBasicAuthHeaders', 'SendRequestOutgoing', diff --git a/packages/proxy/test/unit/http/response-middleware.spec.ts b/packages/proxy/test/unit/http/response-middleware.spec.ts index 981b01ca2dba..320180edf42f 100644 --- a/packages/proxy/test/unit/http/response-middleware.spec.ts +++ b/packages/proxy/test/unit/http/response-middleware.spec.ts @@ -10,6 +10,8 @@ describe('http/response-middleware', function () { it('exports the members in the correct order', function () { expect(_.keys(ResponseMiddleware)).to.have.ordered.members([ 'LogResponse', + 'AttachPlainTextStreamFn', + 'InterceptResponse', 'PatchExpressSetHeader', 'SetInjectionLevel', 'OmitProblematicHeaders', @@ -20,7 +22,6 @@ describe('http/response-middleware', function () { 'CopyResponseStatusCode', 'ClearCyInitialCookie', 'MaybeEndWithEmptyBody', - 'MaybeGunzipBody', 'MaybeInjectHtml', 'MaybeRemoveSecurity', 'GzipBody', diff --git a/packages/runner/package.json b/packages/runner/package.json index ffd1b26f405c..cbed1818f252 100644 --- a/packages/runner/package.json +++ b/packages/runner/package.json @@ -42,6 +42,7 @@ "react": "16.8.6", "react-dom": "16.8.6", "react-input-autosize": "2.2.2", + "regenerator-runtime": "0.13.7", "sinon": "7.5.0", "sinon-chai": "3.3.0", "snap-shot-core": "10.2.1", diff --git a/packages/runner/src/lib/event-manager.js b/packages/runner/src/lib/event-manager.js index 0cd903416df7..369ff4d8aa9d 100644 --- a/packages/runner/src/lib/event-manager.js +++ b/packages/runner/src/lib/event-manager.js @@ -26,6 +26,7 @@ const driverToSocketEvents = 'backend:request automation:request mocha recorder: const driverTestEvents = 'test:before:run:async test:after:run'.split(' ') const driverToLocalEvents = 'viewport:changed config stop url:changed page:loading visit:failed'.split(' ') const socketRerunEvents = 'runner:restart watched:file:changed'.split(' ') +const socketToDriverEvents = 'net:event'.split(' ') const localBus = new EventEmitter() const reporterBus = new EventEmitter() @@ -89,6 +90,12 @@ const eventManager = { ws.on(event, rerun) }) + _.each(socketToDriverEvents, (event) => { + ws.on(event, (...args) => { + Cypress.emit(event, ...args) + }) + }) + const logCommand = (logId) => { const consoleProps = Cypress.runner.getConsolePropsForLogById(logId) diff --git a/packages/runner/src/main.jsx b/packages/runner/src/main.jsx index b39c4123a58e..4405331e2b1e 100644 --- a/packages/runner/src/main.jsx +++ b/packages/runner/src/main.jsx @@ -6,6 +6,9 @@ import { utils as driverUtils } from '@packages/driver' import State from './lib/state' import Container from './app/container' +// to support async/await +import 'regenerator-runtime/runtime' + configure({ enforceActions: 'always' }) const Runner = { diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts new file mode 100644 index 000000000000..4faeaff3b312 --- /dev/null +++ b/packages/server/index.d.ts @@ -0,0 +1,35 @@ +// types for the `server` package + +export namespace CyServer { + export type getRemoteState = () => RemoteState + + // TODO: pull this from main types + export interface Config { + blockHosts: string | string[] + clientRoute: string + experimentalSourceRewriting: boolean + modifyObstructiveCode: boolean + /** + * URL to Cypress's runner. + */ + responseTimeout: number + } + + export interface RemoteState { + auth?: { + username: string + password: string + } + domainName: string + strategy: 'file' | 'http' + origin: string + fileServer: string + visiting: string + } + + export interface Socket { + toDriver: (eventName: string, ...args: any) => void + } +} + +export default CyServer diff --git a/packages/server/lib/config.js b/packages/server/lib/config.js index 5de52ab03c43..908b55f64e52 100644 --- a/packages/server/lib/config.js +++ b/packages/server/lib/config.js @@ -108,12 +108,13 @@ browsers\ // Know experimental flags / values // each should start with "experimental" and be camel cased // example: experimentalComponentTesting -const experimentalConfigKeys = toWords(`\ -experimentalSourceRewriting -experimentalComponentTesting -experimentalShadowDomSupport -experimentalFetchPolyfill\ -`) +const experimentalConfigKeys = [ + 'experimentalSourceRewriting', + 'experimentalComponentTesting', + 'experimentalShadowDomSupport', + 'experimentalFetchPolyfill', + 'experimentalNetworkMocking', +] const CONFIG_DEFAULTS = { port: null, @@ -171,14 +172,13 @@ const CONFIG_DEFAULTS = { // deprecated javascripts: [], - // experimental keys (should all start with "experimental" prefix) - experimentalComponentTesting: false, - // setting related to component testing experiments componentFolder: 'cypress/component', - // TODO: example for component testing with subkeys - // experimentalComponentTesting: { componentFolder: 'cypress/component' } + + // experimental keys (should all start with "experimental" prefix) + experimentalComponentTesting: false, experimentalSourceRewriting: false, + experimentalNetworkMocking: false, experimentalShadowDomSupport: false, experimentalFetchPolyfill: false, retries: { runMode: 0, openMode: 0 }, @@ -222,12 +222,11 @@ const validationRules = { waitForAnimations: v.isBoolean, watchForFileChanges: v.isBoolean, firefoxGcInterval: v.isValidFirefoxGcInterval, - // experimental flag validation here - experimentalComponentTesting: v.isBoolean, - // validation for component testing experiment componentFolder: v.isStringOrFalse, // experimental flag validation below + experimentalComponentTesting: v.isBoolean, experimentalSourceRewriting: v.isBoolean, + experimentalNetworkMocking: v.isBoolean, experimentalShadowDomSupport: v.isBoolean, experimentalFetchPolyfill: v.isBoolean, retries: v.isValidRetriesConfig, diff --git a/packages/server/lib/experiments.ts b/packages/server/lib/experiments.ts index e365e019ac56..0defc28528c7 100644 --- a/packages/server/lib/experiments.ts +++ b/packages/server/lib/experiments.ts @@ -52,6 +52,7 @@ interface StringValues { */ const _summaries: StringValues = { experimentalComponentTesting: 'Framework-specific component testing, uses `componentFolder` to load component specs', + experimentalNetworkMocking: 'Enables `cy.route2`, which can be used to dynamically intercept/stub/await any HTTP request or response (XHRs, fetch, beacons, etc.)', experimentalSourceRewriting: 'Enables AST-based JS/HTML rewriting. This may fix issues caused by the existing regex-based JS/HTML replacement algorithm.', experimentalFetchPolyfill: 'Polyfills `window.fetch` to enable Network spying and stubbing', experimentalShadowDomSupport: 'Enables support for shadow DOM traversal, introduces the `shadow()` command and the `includeShadowDom` option to traversal commands.', @@ -69,6 +70,7 @@ const _summaries: StringValues = { */ const _names: StringValues = { experimentalComponentTesting: 'Component Testing', + experimentalNetworkMocking: 'Experimental network mocking', experimentalSourceRewriting: 'Improved source rewriting', experimentalShadowDomSupport: 'Shadow DOM Support', experimentalFetchPolyfill: 'Fetch polyfill', diff --git a/packages/server/lib/routes.js b/packages/server/lib/routes.js index 21b571c7c20b..f66c49d24f25 100644 --- a/packages/server/lib/routes.js +++ b/packages/server/lib/routes.js @@ -84,13 +84,6 @@ module.exports = ({ app, config, getRemoteState, networkProxy, project, onError res.sendFile(file, { etag: false }) }) - // we've namespaced the initial sending down of our cypress - // app as '__' this route shouldn't ever be used by servers - // and therefore should not conflict - // --- - // TODO: we should additionally send config for the socket.io route, etc - // and any other __cypress namespaced files so that the runner does - // not have to be aware of anything la(check.unemptyString(config.clientRoute), 'missing client route in config', config) app.get(config.clientRoute, (req, res) => { diff --git a/packages/server/lib/server.js b/packages/server/lib/server.js index e1d0f2cbd03c..a303403be8ac 100644 --- a/packages/server/lib/server.js +++ b/packages/server/lib/server.js @@ -21,6 +21,7 @@ const { uri, } = require('@packages/network') const { NetworkProxy } = require('@packages/proxy') +const { netStubbingState } = require('@packages/net-stubbing') const { createInitialWorkers } = require('@packages/rewriter') const origin = require('./util/origin') const ensureUrl = require('./util/ensure-url') @@ -200,16 +201,13 @@ class Server { // TODO: does not need to be an instance anymore this._request = Request() this._nodeProxy = httpProxy.createProxyServer() + this._socket = new Socket(config) const getRemoteState = () => { return this._getRemoteState() } - const getFileServerToken = () => { - return this._fileServer.token - } - - this._networkProxy = new NetworkProxy({ config, getRemoteState, getFileServerToken, request: this._request }) + this.createNetworkProxy(config, getRemoteState) if (config.experimentalSourceRewriting) { createInitialWorkers() @@ -230,6 +228,22 @@ class Server { }) } + createNetworkProxy (config, getRemoteState) { + const getFileServerToken = () => { + return this._fileServer.token + } + + this._netStubbingState = netStubbingState() + this._networkProxy = new NetworkProxy({ + socket: this._socket, + netStubbingState: this._netStubbingState, + config, + getRemoteState, + getFileServerToken, + request: this._request, + }) + } + createHosts (hosts = {}) { return _.each(hosts, (ip, host) => { return evilDns.add(host, ip) @@ -656,6 +670,15 @@ class Server { }, }) + if (options.selfProxy) { + // TODO: this is being used to force cy.visits to be interceptable by network stubbing + // however, network errors will be obsfucated by the proxying so this is not an ideal solution + _.assign(options, { + proxy: `http://127.0.0.1:${this._port()}`, + agent: null, + }) + } + debug('sending request with options %o', options) return runPhase(() => { @@ -865,12 +888,13 @@ class Server { startWebsockets (automation, config, options = {}) { options.onResolveUrl = this._onResolveUrl.bind(this) options.onRequest = this._onRequest.bind(this) + options.netStubbingState = this._netStubbingState options.onResetServerState = () => { - return this._networkProxy.reset() + this._networkProxy.reset() + this._netStubbingState.reset() } - this._socket = new Socket(config) this._socket.startListening(this._server, automation, config, options) return this._normalizeReqUrl(this._server) diff --git a/packages/server/lib/socket.js b/packages/server/lib/socket.js index 36797ff53c1f..67496af1b065 100644 --- a/packages/server/lib/socket.js +++ b/packages/server/lib/socket.js @@ -14,6 +14,7 @@ const files = require('./files') const fixture = require('./fixture') const errors = require('./errors') const preprocessor = require('./plugins/preprocessor') +const netStubbing = require('@packages/net-stubbing') const firefoxUtil = require('./browsers/firefox-util').default const runnerEvents = [ @@ -121,6 +122,10 @@ class Socket { return socket && socket.connected } + toDriver (event, ...data) { + return this.io && this.io.emit(event, ...data) + } + onAutomation (socket, message, data, id) { // instead of throwing immediately here perhaps we need // to make this more resilient by automatically retrying @@ -191,6 +196,8 @@ class Socket { return automation.request(message, data, onAutomationClientRequestCallback) } + const getFixture = (path, opts) => fixture.get(config.fixturesFolder, path, opts) + return this.io.on('connection', (socket) => { debug('socket connected') @@ -363,7 +370,7 @@ class Socket { debug('backend:request %o', { eventName, args }) - const backendRequest = function () { + const backendRequest = () => { switch (eventName) { case 'preserve:run:state': existingState = args[0] @@ -383,11 +390,20 @@ class Socket { case 'firefox:force:gc': return firefoxUtil.collectGarbage() case 'get:fixture': - return fixture.get(config.fixturesFolder, args[0], args[1]) + return getFixture(args[0], args[1]) case 'read:file': return files.readFile(config.projectRoot, args[0], args[1]) case 'write:file': return files.writeFile(config.projectRoot, args[0], args[1], args[2]) + case 'net': + return netStubbing.onNetEvent({ + eventName: args[0], + frame: args[1], + state: options.netStubbingState, + socket: this, + getFixture, + args, + }) case 'exec': return exec.run(config.projectRoot, args[0]) case 'task': diff --git a/packages/server/lib/util/passthru_stream.ts b/packages/server/lib/util/passthru_stream.ts new file mode 100644 index 000000000000..31b3e8e0b962 --- /dev/null +++ b/packages/server/lib/util/passthru_stream.ts @@ -0,0 +1,9 @@ +import { Transform } from 'stream' + +const through = require('through') + +export function passthruStream (): Transform { + return through(function (this: InternalStream, chunk) { + this.queue(chunk) + }) +} diff --git a/packages/server/package.json b/packages/server/package.json index 9c9ae61ef23e..11bebeedbc67 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -108,6 +108,7 @@ "syntax-error": "1.4.0", "systeminformation": "4.26.9", "term-size": "2.1.0", + "through": "2.3.8", "tough-cookie": "4.0.0", "trash": "5.2.0", "tree-kill": "1.2.2", @@ -187,6 +188,7 @@ "lib", "patches" ], + "types": "index.d.ts", "productName": "Cypress", "workspaces": { "nohoist": [ diff --git a/packages/server/test/unit/config_spec.js b/packages/server/test/unit/config_spec.js index 58bf50345bbb..b8957672f898 100644 --- a/packages/server/test/unit/config_spec.js +++ b/packages/server/test/unit/config_spec.js @@ -1190,6 +1190,7 @@ describe('lib/config', () => { experimentalComponentTesting: { value: false, from: 'default' }, componentFolder: { value: 'cypress/component', from: 'default' }, experimentalShadowDomSupport: { value: false, from: 'default' }, + experimentalNetworkMocking: { value: false, from: 'default' }, experimentalFetchPolyfill: { value: false, from: 'default' }, retries: { value: { runMode: 0, openMode: 0 }, from: 'default' }, }) @@ -1268,6 +1269,7 @@ describe('lib/config', () => { experimentalComponentTesting: { value: false, from: 'default' }, componentFolder: { value: 'cypress/component', from: 'default' }, experimentalShadowDomSupport: { value: false, from: 'default' }, + experimentalNetworkMocking: { value: false, from: 'default' }, experimentalFetchPolyfill: { value: false, from: 'default' }, retries: { value: { runMode: 0, openMode: 0 }, from: 'default' }, env: { diff --git a/packages/ts/index.d.ts b/packages/ts/index.d.ts index a6cc909deca4..94e910ea33b8 100644 --- a/packages/ts/index.d.ts +++ b/packages/ts/index.d.ts @@ -41,6 +41,10 @@ declare module 'http' { uri: Url } + interface OutgoingMessage { + destroy(error?: Error): void + } + export const CRLF: string } @@ -50,6 +54,10 @@ declare module 'https' { } } +declare interface InternalStream { + queue(str: string | null): void +} + declare module 'net' { type family = 4 | 6 type TCPSocket = {} diff --git a/packages/web-config/node-register.js b/packages/web-config/node-register.js index 7fe66473f7f9..74b35713302f 100644 --- a/packages/web-config/node-register.js +++ b/packages/web-config/node-register.js @@ -7,7 +7,7 @@ require('@babel/register')({ 'presets': [ require.resolve('@babel/preset-env'), require.resolve('@babel/preset-react'), - require.resolve('@babel/preset-typescript'), + [require.resolve('@babel/preset-typescript'), { allowNamespaces: true }], ], // Setting this will remove the currently hooked extensions of `.es6`, `.es`, `.jsx`, `.mjs` diff --git a/packages/web-config/webpack.config.base.ts b/packages/web-config/webpack.config.base.ts index 289ce33f21d1..afe7693263d7 100644 --- a/packages/web-config/webpack.config.base.ts +++ b/packages/web-config/webpack.config.base.ts @@ -76,7 +76,7 @@ const getCommonConfig = () => { presets: [ require.resolve('@babel/preset-env'), require.resolve('@babel/preset-react'), - require.resolve('@babel/preset-typescript'), + [require.resolve('@babel/preset-typescript'), { allowNamespaces: true }], ], babelrc: false, }, diff --git a/scripts/binary/zip.js b/scripts/binary/zip.js index c6bd242e8556..47ab6979616b 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' ? 245 : 195 + const MAX_ALLOWED_SIZE_MB = os.platform() === 'win32' ? 245 : 200 const MAX_ZIP_FILE_SIZE = megaBytes(MAX_ALLOWED_SIZE_MB) if (stats.size > MAX_ZIP_FILE_SIZE) { diff --git a/yarn.lock b/yarn.lock index d23daca2364c..e61809f88aa4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4398,6 +4398,11 @@ resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.7.tgz#315d570ccb56c53452ff8638738df60726d5b6ea" integrity sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ== +"@types/mocha@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-7.0.2.tgz#b17f16cf933597e10d6d78eae3251e692ce8b0ce" + integrity sha512-ZvO2tAcjmMi8V/5Z3JsyofMe3hasRcaw88cto5etSVMwVQfeivGAlEYmaQgceUSVYFofVjT+ioHsATjdWcFt1w== + "@types/mock-require@2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/mock-require/-/mock-require-2.0.0.tgz#57a4f0db0b4b6274f610a2d2c20beb3c842181e1" @@ -7198,6 +7203,13 @@ big.js@^5.2.2: resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== +bin-up@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/bin-up/-/bin-up-1.2.0.tgz#57976407e82b9c39e92d9e22eee0c3e298a95c4c" + integrity sha512-OOv0fU6dcy/2C4QsY0MlkA8c5ro/cqC7xNJ15mDbA35Oynn5vPt8rCiR3kxitQuOGe6vkJH6le6Pg1tQqySLkA== + dependencies: + execa "0.8.0" + binary-extensions@^1.0.0: version "1.13.1" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" @@ -11600,6 +11612,19 @@ execa@0.11.0: signal-exit "^3.0.0" strip-eof "^1.0.0" +execa@0.8.0, execa@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.8.0.tgz#d8d76bbc1b55217ed190fd6dd49d3c774ecfc8da" + integrity sha1-2NdrvBtVIX7RkP1t1J08d07PyNo= + dependencies: + cross-spawn "^5.0.1" + get-stream "^3.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + execa@1.0.0, execa@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" @@ -11680,19 +11705,6 @@ execa@^0.7.0: signal-exit "^3.0.0" strip-eof "^1.0.0" -execa@^0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-0.8.0.tgz#d8d76bbc1b55217ed190fd6dd49d3c774ecfc8da" - integrity sha1-2NdrvBtVIX7RkP1t1J08d07PyNo= - dependencies: - cross-spawn "^5.0.1" - get-stream "^3.0.0" - is-stream "^1.1.0" - npm-run-path "^2.0.0" - p-finally "^1.0.0" - signal-exit "^3.0.0" - strip-eof "^1.0.0" - execa@^2.0.3: version "2.1.0" resolved "https://registry.yarnpkg.com/execa/-/execa-2.1.0.tgz#e5d3ecd837d2a60ec50f3da78fd39767747bbe99" @@ -14863,7 +14875,7 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" -is-html@2.0.0: +is-html@2.0.0, is-html@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-html/-/is-html-2.0.0.tgz#b3ab2e27ccb7a12235448f51f115a6690f435fc8" integrity sha512-S+OpgB5i7wzIue/YSE5hg0e5ZYfG3hhpNh9KGl6ayJ38p7ED6wxQLd1TV91xHpcTvw90KMJ9EwN3F/iNflHBVg== @@ -17750,7 +17762,7 @@ mkdirp@0.3.0: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e" integrity sha1-G79asbqCevI1dRQ0kEJkVfSB/h4= -mkdirp@0.5, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.4, mkdirp@~0.5.0, mkdirp@~0.5.1: +mkdirp@0.5, mkdirp@0.5.5, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.4, mkdirp@~0.5.0, mkdirp@~0.5.1: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== @@ -17948,6 +17960,36 @@ mocha@7.1.0: yargs-parser "13.1.1" yargs-unparser "1.6.0" +mocha@7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-7.1.2.tgz#8e40d198acf91a52ace122cd7599c9ab857b29e6" + integrity sha512-o96kdRKMKI3E8U0bjnfqW4QMk12MwZ4mhdBTf+B5a1q9+aq2HRnj+3ZdJu0B/ZhJeK78MgYuv6L8d/rA5AeBJA== + dependencies: + ansi-colors "3.2.3" + browser-stdout "1.3.1" + chokidar "3.3.0" + debug "3.2.6" + diff "3.5.0" + escape-string-regexp "1.0.5" + find-up "3.0.0" + glob "7.1.3" + growl "1.10.5" + he "1.2.0" + js-yaml "3.13.1" + log-symbols "3.0.0" + minimatch "3.0.4" + mkdirp "0.5.5" + ms "2.1.1" + node-environment-flags "1.0.6" + object.assign "4.1.0" + strip-json-comments "2.0.1" + supports-color "6.0.0" + which "1.3.1" + wide-align "1.1.3" + yargs "13.3.2" + yargs-parser "13.1.2" + yargs-unparser "1.6.0" + mocha@>=1.13.0: version "8.0.1" resolved "https://registry.yarnpkg.com/mocha/-/mocha-8.0.1.tgz#fe01f0530362df271aa8f99510447bc38b88d8ed" @@ -21153,7 +21195,7 @@ read@1, read@~1.0.1: string_decoder "~1.1.1" util-deprecate "~1.0.1" -"readable-stream@2 || 3", readable-stream@^3.0.2, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: +"readable-stream@2 || 3", "readable-stream@>= 0.3.0", readable-stream@^3.0.2, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -21354,6 +21396,11 @@ regenerate@^1.2.1, regenerate@^1.4.0: resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.1.tgz#cad92ad8e6b591773485fbe05a485caf4f457e6f" integrity sha512-j2+C8+NtXQgEKWk49MMP5P/u2GhnahTtVkRIHr5R5lVRlbKvmQ+oS+A5aLKWp2ma5VkT8sh6v+v4hbH0YHR66A== +regenerator-runtime@0.13.7: + version "0.13.7" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" + integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== + regenerator-runtime@^0.10.5: version "0.10.5" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz#336c3efc1220adcedda2c9fab67b5a7955a33658" @@ -23346,6 +23393,13 @@ stream-http@^3.0.0: readable-stream "^3.6.0" xtend "^4.0.2" +"stream-parser@>= 0.0.2": + version "0.3.1" + resolved "https://registry.yarnpkg.com/stream-parser/-/stream-parser-0.3.1.tgz#1618548694420021a1182ff0af1911c129761773" + integrity sha1-FhhUhpRCACGhGC/wrxkRwSl2F3M= + dependencies: + debug "2" + stream-shift@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" @@ -24010,6 +24064,14 @@ throat@^4.0.0: resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a" integrity sha1-iQN8vJLFarGJJua6TLsgDhVnKmo= +throttle@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/throttle/-/throttle-1.0.3.tgz#8a32e4a15f1763d997948317c5ebe3ad8a41e4b7" + integrity sha1-ijLkoV8XY9mXlIMXxevjrYpB5Lc= + dependencies: + readable-stream ">= 0.3.0" + stream-parser ">= 0.0.2" + throttleit@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c"