diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index d1d0008f3d..0a1d194fff 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -5517,6 +5517,7 @@ declare namespace Cypress { interface Log { end(): Log + error(error: Error): Log finish(): void get(attr: K): LogConfig[K] get(): LogConfig diff --git a/packages/driver/cypress/integration/commands/net_stubbing_spec.ts b/packages/driver/cypress/integration/commands/net_stubbing_spec.ts index 3e3d59d641..4210c22deb 100644 --- a/packages/driver/cypress/integration/commands/net_stubbing_spec.ts +++ b/packages/driver/cypress/integration/commands/net_stubbing_spec.ts @@ -564,63 +564,6 @@ describe('network stubbing', { retries: 2 }, function () { }) }) - it('has displayName req for spies', function () { - cy.intercept('/foo*').as('getFoo') - .then(() => { - $.get('/foo') - }) - .wait('@getFoo') - .then(() => { - const log = _.last(cy.queue.logs()) as any - - expect(log.get('displayName')).to.eq('req') - }) - }) - - it('has displayName req stub for stubs', function () { - cy.intercept('/foo*', { body: 'foo' }).as('getFoo') - .then(() => { - $.get('/foo') - }) - .wait('@getFoo') - .then(() => { - const log = _.last(cy.queue.logs()) as any - - expect(log.get('displayName')).to.eq('req stub') - }) - }) - - it('has displayName req fn for request handlers', function () { - cy.intercept('/foo*', () => {}).as('getFoo') - .then(() => { - $.get('/foo') - }) - .wait('@getFoo') - .then(() => { - const log = _.last(cy.queue.logs()) as any - - expect(log.get('displayName')).to.eq('req fn') - }) - }) - - // TODO: implement log niceties - it.skip('#consoleProps', function () { - cy.intercept('*', { - 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', - }) - }) - }) - describe('numResponses', function () { it('is initially 0', function () { cy.intercept(/foo/, {}).then(() => { @@ -3241,7 +3184,7 @@ describe('network stubbing', { retries: 2 }, function () { $.get('/fixtures/app.json') }).wait('@getFoo').then(function (res) { const log = cy.queue.logs({ - displayName: 'req', + displayName: 'xhr', })[0] expect(log.get('alias')).to.eq('getFoo') diff --git a/packages/driver/cypress/integration/commands/xhr_spec.js b/packages/driver/cypress/integration/commands/xhr_spec.js index 8e739383e6..54f99145c0 100644 --- a/packages/driver/cypress/integration/commands/xhr_spec.js +++ b/packages/driver/cypress/integration/commands/xhr_spec.js @@ -794,7 +794,7 @@ describe('src/cy/commands/xhr', () => { this.logs = [] cy.on('log:added', (attrs, log) => { - if (attrs.name === 'xhr') { + if (['xhr', 'request'].includes(attrs.name)) { this.lastLog = log this.logs.push(log) } @@ -817,13 +817,15 @@ describe('src/cy/commands/xhr', () => { expect(lastLog.pick('name', 'displayName', 'event', 'alias', 'aliasType', 'state')).to.deep.eq({ name: 'xhr', - displayName: 'xhr stub', + displayName: 'xhr', event: true, alias: 'getFoo', aliasType: 'route', state: 'pending', }) + expect(lastLog.get('renderProps')()).to.include({ wentToOrigin: false }) + const snapshots = lastLog.get('snapshots') expect(snapshots.length).to.eq(1) @@ -846,13 +848,15 @@ describe('src/cy/commands/xhr', () => { expect(lastLog.pick('name', 'displayName', 'event', 'alias', 'aliasType', 'state')).to.deep.eq({ name: 'xhr', - displayName: 'xhr stub', + displayName: 'xhr', event: true, alias: 'getFoo', aliasType: 'route', state: 'pending', }) + expect(lastLog.get('renderProps')()).to.include({ wentToOrigin: false }) + const snapshots = lastLog.get('snapshots') expect(snapshots.length).to.eq(1) @@ -971,7 +975,7 @@ describe('src/cy/commands/xhr', () => { it('logs obj', function () { const obj = { name: 'xhr', - displayName: 'xhr stub', + displayName: 'xhr', event: true, message: '', type: 'parent', @@ -1012,7 +1016,7 @@ describe('src/cy/commands/xhr', () => { this.logs = [] cy.on('log:added', (attrs, log) => { - if (attrs.name === 'xhr') { + if (['xhr', 'request'].includes(attrs.name)) { this.lastLog = log this.logs.push(log) } @@ -1026,7 +1030,7 @@ describe('src/cy/commands/xhr', () => { const { lastLog } = this expect(this.logs.length).to.eq(1) - expect(lastLog.get('name')).to.eq('xhr') + expect(lastLog.get('name')).to.eq('request') expect(lastLog.get('error').message).contain('foo is not defined') done() @@ -1049,7 +1053,7 @@ describe('src/cy/commands/xhr', () => { const { lastLog } = this expect(this.logs.length).to.eq(1) - expect(lastLog.get('name')).to.eq('xhr') + expect(lastLog.get('name')).to.eq('request') expect(err.message).to.include(lastLog.get('error').message) expect(err.message).to.include(e.message) @@ -1203,7 +1207,7 @@ describe('src/cy/commands/xhr', () => { this.logs = [] cy.on('log:added', (attrs, log) => { - if (attrs.name === 'xhr') { + if (['xhr', 'request'].includes(attrs.name)) { this.lastLog = log this.logs.push(log) } @@ -1751,7 +1755,7 @@ describe('src/cy/commands/xhr', () => { return null }) .wait('@getFoo').then((xhr) => { - const log = cy.queue.logs({ name: 'xhr' })[0] + const log = cy.queue.logs({ name: 'request' })[0] expect(log.get('displayName')).to.eq('xhr') expect(log.get('alias')).to.eq('getFoo') @@ -2182,7 +2186,7 @@ describe('src/cy/commands/xhr', () => { this.logs = [] cy.on('log:added', (attrs, log) => { - if (attrs.name === 'xhr') { + if (['xhr', 'request'].includes(attrs.name)) { this.lastLog = log this.logs.push(log) } @@ -2349,16 +2353,16 @@ describe('src/cy/commands/xhr', () => { }) }) - it('says Stubbed: No when request isnt forced 404', function () { - expect(this.lastLog.invoke('consoleProps').Stubbed).to.eq('No') + it('no status when request isnt forced 404', function () { + expect(this.lastLog.invoke('consoleProps').Status).to.be.undefined }) it('logs request + response headers', () => { cy.then(function () { - const consoleProps = this.lastLog.invoke('consoleProps') - - expect(consoleProps.Request.headers).to.be.an('object') - expect(consoleProps.Response.headers).to.be.an('object') + cy.wrap(this).its('lastLog').invoke('invoke', 'consoleProps').should((consoleProps) => { + expect(consoleProps['Request Headers']).to.be.an('object') + expect(consoleProps['Response Headers']).to.be.an('object') + }) }) }) @@ -2366,26 +2370,28 @@ describe('src/cy/commands/xhr', () => { cy.then(function () { const { xhr } = cy.state('responses')[0] - const consoleProps = _.pick(this.lastLog.invoke('consoleProps'), 'Method', 'Status', 'URL', 'XHR') + cy.wrap(this).its('lastLog').invoke('invoke', 'consoleProps').should((consoleProps) => { + expect(consoleProps).to.include({ + Method: 'GET', + URL: 'http://localhost:3500/fixtures/app.json', + 'Request went to origin?': 'yes', + XHR: xhr.xhr, + }) - expect(consoleProps).to.deep.eq({ - Method: 'GET', - URL: 'http://localhost:3500/fixtures/app.json', - Status: '200 (OK)', - XHR: xhr.xhr, + expect(consoleProps['Response Status Code']).to.be.oneOf([200, 304]) }) }) }) it('logs response', () => { cy.then(function () { - const consoleProps = this.lastLog.invoke('consoleProps') - - expect(consoleProps.Response.body).to.deep.eq({ - some: 'json', - foo: { - bar: 'baz', - }, + cy.wrap(this).its('lastLog').invoke('invoke', 'consoleProps').should((consoleProps) => { + expect(consoleProps['Response Body']).to.deep.eq({ + some: 'json', + foo: { + bar: 'baz', + }, + }) }) }) }) @@ -2410,7 +2416,7 @@ describe('src/cy/commands/xhr', () => { this.logs = [] cy.on('log:added', (attrs, log) => { - if (attrs.name === 'xhr') { + if (['xhr', 'request'].includes(attrs.name)) { this.lastLog = log this.logs.push(log) } @@ -2543,7 +2549,7 @@ describe('src/cy/commands/xhr', () => { let log = null cy.on('log:changed', (attrs, l) => { - if (attrs.name === 'xhr') { + if (['xhr', 'request'].includes(attrs.name)) { if (!log) { log = l } @@ -2561,11 +2567,11 @@ describe('src/cy/commands/xhr', () => { cy.wrap(null).should(() => { expect(log.get('state')).to.eq('failed') - expect(log.invoke('renderProps')).to.deep.eq({ - message: 'GET (aborted) /timeout?ms=999', - indicator: 'aborted', + expect(log.invoke('renderProps')).to.include({ + message: 'GET /timeout?ms=999', }) + expect(log.get('error')).to.be.an('Error') expect(xhr.aborted).to.be.true }) }) @@ -2576,7 +2582,7 @@ describe('src/cy/commands/xhr', () => { let log = null cy.on('log:changed', (attrs, l) => { - if (attrs.name === 'xhr') { + if (['xhr', 'request'].includes(attrs.name)) { if (!log) { log = l } @@ -2605,7 +2611,7 @@ describe('src/cy/commands/xhr', () => { let log = null cy.on('log:changed', (attrs, l) => { - if (attrs.name === 'xhr') { + if (['xhr', 'request'].includes(attrs.name)) { if (!log) { log = l } diff --git a/packages/driver/cypress/integration/cypress/proxy-logging-spec.ts b/packages/driver/cypress/integration/cypress/proxy-logging-spec.ts new file mode 100644 index 0000000000..e71814124f --- /dev/null +++ b/packages/driver/cypress/integration/cypress/proxy-logging-spec.ts @@ -0,0 +1,417 @@ +describe('Proxy Logging', () => { + const { _ } = Cypress + + const url = '/testFlag' + const alias = 'aliasName' + + function testFlag (expectStatus, expectInterceptions, setupFn, getFn) { + return () => { + setupFn() + + let resolve + const p = new Promise((_resolve) => resolve = _resolve) + + function testLog (log) { + expect(log.alias).to.eq(expectInterceptions.length ? alias : undefined) + expect(log.renderProps).to.deep.include({ + interceptions: expectInterceptions, + ...(expectStatus ? { status: expectStatus } : {}), + }) + + resolve() + } + + cy.then(() => { + cy.on('log:changed', (log) => { + if (['request', 'xhr'].includes(log.name)) { + try { + testLog(log) + resolve() + } catch (err) { + // eslint-disable-next-line no-console + console.error('assertions failed:', err) + } + } + }) + + getFn(url) + }).then(() => p) + + if (expectStatus) { + cy.wait(`@${alias}`) + } + } + } + + beforeEach(() => { + // block race conditions caused by log update debouncing + // @ts-ignore + Cypress.config('logAttrsDelay', 0) + }) + + context('request logging', () => { + it('fetch log shows resource type, url, method, and status code and has expected snapshots and consoleProps', (done) => { + fetch('/some-url') + + // trigger: Cypress.Log() called + cy.once('log:added', (log) => { + expect(log.snapshots).to.be.undefined + expect(log.displayName).to.eq('fetch') + expect(log.renderProps).to.include({ + indicator: 'pending', + message: 'GET /some-url', + }) + + expect(log.consoleProps).to.include({ + Method: 'GET', + 'Resource Type': 'fetch', + 'Request went to origin?': 'yes', + 'URL': 'http://localhost:3500/some-url', + }) + + // case depends on browser + const refererKey = _.keys(log.consoleProps['Request Headers']).find((k) => k.toLowerCase() === 'referer') || 'referer' + + expect(log.consoleProps['Request Headers']).to.include({ + [refererKey]: window.location.href, + }) + + expect(log.consoleProps).to.not.have.property('Response Headers') + expect(log.consoleProps).to.not.have.property('Matched `cy.intercept()`') + + // trigger: .snapshot('request') + cy.once('log:changed', (log) => { + expect(log.snapshots.map((v) => v.name)).to.deep.eq(['request']) + + // trigger: .snapshot('response') + cy.once('log:changed', (log) => { + expect(log.snapshots.map((v) => v.name)).to.deep.eq(['request', 'response']) + expect(log.consoleProps['Response Headers']).to.include({ + 'x-powered-by': 'Express', + }) + + expect(log.consoleProps).to.not.have.property('Matched `cy.intercept()`') + expect(log.renderProps).to.include({ + indicator: 'bad', + message: 'GET 404 /some-url', + }) + + expect(Object.keys(log.consoleProps)).to.deep.eq( + ['Event', 'Resource Type', 'Method', 'URL', 'Request went to origin?', 'Request Headers', 'Response Status Code', 'Response Headers'], + ) + + done() + }) + }) + }) + }) + + it('does not log an unintercepted non-xhr/fetch request', (done) => { + const img = new Image() + const logs: any[] = [] + let imgLoaded = false + + cy.on('log:added', (log) => { + if (imgLoaded) return + + logs.push(log) + }) + + img.onload = () => { + imgLoaded = true + expect(logs).to.have.length(0) + done() + } + + img.src = `/fixtures/media/cypress.png?${Date.now()}` + }) + + context('with cy.intercept()', () => { + it('shows non-xhr/fetch log if intercepted', (done) => { + const src = `/fixtures/media/cypress.png?${Date.now()}` + + cy.intercept('/fixtures/**/*.png*') + .then(() => { + cy.once('log:added', (log) => { + expect(log.displayName).to.eq('image') + expect(log.renderProps).to.include({ + indicator: 'pending', + message: `GET ${src}`, + }) + + done() + }) + + const img = new Image() + + img.src = src + }) + }) + + it('shows cy.visit if intercepted', () => { + cy.intercept('/fixtures/empty.html') + .then(() => { + // trigger: cy.visit() + cy.once('log:added', (log) => { + expect(log.name).to.eq('visit') + // trigger: intercept Cypress.Log + cy.once('log:added', (log) => { + expect(log.displayName).to.eq('document') + }) + }) + }) + .visit('/fixtures/empty.html') + }) + + it('intercept log has consoleProps with intercept info', (done) => { + cy.intercept('/some-url', 'stubbed response').as('alias') + .then(() => { + fetch('/some-url') + }) + + cy.on('log:changed', (log) => { + if (log.displayName !== 'fetch') return + + try { + expect(log.renderProps).to.deep.include({ + message: 'GET 200 /some-url', + indicator: 'successful', + status: undefined, + interceptions: [{ + alias: 'alias', + command: 'intercept', + type: 'stub', + }], + }) + + expect(Object.keys(log.consoleProps)).to.deep.eq( + ['Event', 'Resource Type', 'Method', 'URL', 'Request went to origin?', 'Matched `cy.intercept()`', 'Request Headers', 'Response Status Code', 'Response Headers', 'Response Body'], + ) + + const interceptProps = log.consoleProps['Matched `cy.intercept()`'] + + expect(interceptProps).to.deep.eq({ + Alias: 'alias', + Request: { + method: 'GET', + url: 'http://localhost:3500/some-url', + body: '', + httpVersion: '1.1', + responseTimeout: Cypress.config('responseTimeout'), + headers: interceptProps.Request.headers, + }, + Response: { + body: 'stubbed response', + statusCode: 200, + url: 'http://localhost:3500/some-url', + headers: interceptProps.Response.headers, + }, + RouteMatcher: { + url: '/some-url', + }, + RouteHandler: 'stubbed response', + 'RouteHandler Type': 'StaticResponse stub', + }) + + done() + } catch (err) { + // eslint-disable-next-line no-console + console.error('assertion failed:', err) + } + }) + }) + + it('works with forceNetworkError', () => { + const logs: any[] = [] + + cy.on('log:added', (log) => { + if (log.displayName === 'fetch') { + logs.push(log) + } + }) + + cy.intercept('/foo', { forceNetworkError: true }).as('alias') + .then(() => { + return fetch('/foo') + .catch(() => {}) + }) + .wrap(logs) + .should((logs) => { + // retries... + expect(logs).to.have.length.greaterThan(2) + + for (const log of logs) { + expect(log.err).to.include({ name: 'Error' }) + expect(log.consoleProps['Error']).to.be.an('Error') + expect(log.snapshots.map((v) => v.name)).to.deep.eq(['request', 'error']) + expect(log.state).to.eq('failed') + } + }) + }) + + context('flags', () => { + const testFlagFetch = (expectStatus, expectInterceptions, setupFn) => { + return testFlag(expectStatus, expectInterceptions, setupFn, (url) => fetch(url)) + } + + it('is unflagged when not intercepted', testFlagFetch( + undefined, + [], + () => {}, + )) + + it('spied flagged as expected', testFlagFetch( + undefined, + [{ + command: 'intercept', + alias, + type: 'spy', + }], + () => { + cy.intercept(url).as(alias) + }, + )) + + it('spy function flagged as expected', testFlagFetch( + undefined, + [{ + command: 'intercept', + alias, + type: 'function', + }], + () => { + cy.intercept(url, () => {}).as(alias) + }, + )) + + it('stubbed flagged as expected', testFlagFetch( + undefined, + [{ + command: 'intercept', + alias, + type: 'stub', + }], + () => { + cy.intercept(url, 'stubbed response').as(alias) + }, + )) + + it('stubbed flagged as expected with req.reply', testFlagFetch( + undefined, + [{ + command: 'intercept', + alias, + type: 'function', + }], + () => { + cy.intercept(url, (req) => { + req.headers.foo = 'bar' + req.reply('stubby mc stub') + }).as(alias) + }, + )) + + it('req modified flagged as expected', testFlagFetch( + 'req modified', + [{ + command: 'intercept', + alias, + type: 'function', + }], + () => { + cy.intercept(url, (req) => { + req.headers.foo = 'bar' + }).as(alias) + }, + )) + + it('res modified flagged as expected', testFlagFetch( + 'res modified', + [{ + command: 'intercept', + alias, + type: 'function', + }], + () => { + cy.intercept(url, (req) => { + req.continue((res) => { + res.headers.foo = 'bar' + }) + }).as(alias) + }, + )) + + it('req + res modified flagged as expected', testFlagFetch( + 'req + res modified', + [{ + command: 'intercept', + alias, + type: 'function', + }], + () => { + cy.intercept(url, (req) => { + req.headers.foo = 'bar' + req.continue((res) => { + res.headers.foo = 'bar' + }) + }).as(alias) + }, + )) + }) + }) + + context('with cy.route()', () => { + context('flags', () => { + let $XMLHttpRequest + + const testFlagXhr = (expectStatus, expectInterceptions, setupFn) => { + return testFlag(expectStatus, expectInterceptions, setupFn, (url) => { + const xhr = new $XMLHttpRequest() + + xhr.open('GET', url) + xhr.send() + }) + } + + beforeEach(() => { + cy.window() + .then(({ XMLHttpRequest }) => { + $XMLHttpRequest = XMLHttpRequest + }) + }) + + it('is unflagged when not routed', testFlagXhr( + undefined, + [], + () => {}, + )) + + it('spied flagged as expected', testFlagXhr( + undefined, + [{ + command: 'route', + alias, + type: 'spy', + }], + () => { + cy.server() + cy.route(`${url}`).as(alias) + }, + )) + + it('stubbed flagged as expected', testFlagXhr( + undefined, + [{ + command: 'route', + alias, + type: 'stub', + }], + () => { + cy.server() + cy.route(url, 'stubbed response').as(alias) + }, + )) + }) + }) + }) +}) diff --git a/packages/driver/cypress/integration/issues/761_2968_3973_spec.js b/packages/driver/cypress/integration/issues/761_2968_3973_spec.js index 2349faac36..43eee60216 100644 --- a/packages/driver/cypress/integration/issues/761_2968_3973_spec.js +++ b/packages/driver/cypress/integration/issues/761_2968_3973_spec.js @@ -131,9 +131,9 @@ if (Cypress.isBrowser('chrome')) { expect(stub).not.to.be.called expect(secondLog.get('state')).to.eq('failed') - expect(secondLog.invoke('renderProps')).to.deep.eq({ - message: 'GET (canceled) /timeout?ms=2000', - indicator: 'aborted', + expect(secondLog.invoke('renderProps')).to.include({ + message: 'GET /timeout?ms=2000', + indicator: 'pending', }) }) }) diff --git a/packages/driver/src/cy/commands/xhr.js b/packages/driver/src/cy/commands/xhr.js index 9cec79b4b4..40c0eb45b8 100644 --- a/packages/driver/src/cy/commands/xhr.js +++ b/packages/driver/src/cy/commands/xhr.js @@ -41,8 +41,6 @@ const unavailableErr = () => { return $errUtils.throwErrByPath('server.unavailable') } -const getDisplayName = (route) => _.isNil(route?.response) ? 'xhr' : 'xhr stub' - const stripOrigin = (url) => { const location = $Location.create(url) @@ -109,10 +107,12 @@ const startXhrServer = (cy, state, config) => { rl.set('numResponses', numResponses + 1) } + const isStubbed = route && !_.isNil(route.response) + const log = logs[xhr.id] = Cypress.log({ message: '', name: 'xhr', - displayName: getDisplayName(route), + displayName: 'xhr', alias, aliasType: 'route', type: 'parent', @@ -126,7 +126,7 @@ const startXhrServer = (cy, state, config) => { 'Matched URL': route?.url, Status: xhr.statusMessage, Duration: xhr.duration, - Stubbed: _.isNil(route?.response) ? 'No' : 'Yes', + Stubbed: isStubbed ? 'Yes' : 'No', Request: xhr.request, Response: xhr.response, XHR: xhr._getXhr(), @@ -172,10 +172,20 @@ const startXhrServer = (cy, state, config) => { return { indicator, message: `${xhr.method} ${status} ${stripOrigin(xhr.url)}`, + interceptions: route ? [ + { + command: 'route', + type: isStubbed ? 'stub' : 'spy', + alias, + }, + ] : [], + wentToOrigin: !isStubbed, } }, }) + Cypress.ProxyLogging.addXhrLog({ xhr, route, log, stack }) + return log.snapshot('request') }, @@ -185,7 +195,14 @@ const startXhrServer = (cy, state, config) => { const log = logs[xhr.id] if (log) { - return log.snapshot('response').end() + // the xhr log can already have a snapshot if it's been correlated with a proxy request (not xhr stubbed), so check first + const hasResponseSnapshot = log.get('snapshots')?.find((v) => v.name === 'response') + + if (!hasResponseSnapshot) { + log.snapshot('response') + } + + log.end() } }, diff --git a/packages/driver/src/cy/net-stubbing/events/after-response.ts b/packages/driver/src/cy/net-stubbing/events/after-response.ts index 0f5fa70dae..8a060b0116 100644 --- a/packages/driver/src/cy/net-stubbing/events/after-response.ts +++ b/packages/driver/src/cy/net-stubbing/events/after-response.ts @@ -16,9 +16,6 @@ export const onAfterResponse: HandlerFn = async request.state = 'Complete' - request.log.fireChangeEvent() - request.log.end() - // @ts-ignore userHandler && await userHandler(request.response!) diff --git a/packages/driver/src/cy/net-stubbing/events/before-request.ts b/packages/driver/src/cy/net-stubbing/events/before-request.ts index a2b2f5c619..856fc67f4a 100644 --- a/packages/driver/src/cy/net-stubbing/events/before-request.ts +++ b/packages/driver/src/cy/net-stubbing/events/before-request.ts @@ -1,7 +1,6 @@ import _ from 'lodash' import { - Route, Interception, CyHttpMessages, SERIALIZABLE_REQ_PROPS, @@ -24,58 +23,16 @@ type Result = HandlerResult const validEvents = ['before:response', 'response', 'after:response'] -const getDisplayUrl = (url: string) => { - if (url.startsWith(window.location.origin)) { - return url.slice(window.location.origin.length) - } - - return url -} - export const onBeforeRequest: HandlerFn = (Cypress, frame, userHandler, { getRoute, getRequest, emitNetEvent, sendStaticResponse }) => { - function getRequestLog (route: Route, request: Omit) { - const message = _.compact([ - request.request.method, - request.response && request.response.statusCode, - getDisplayUrl(request.request.url), - request.state, - ]).join(' ') - - const displayName = route.handler ? (_.isFunction(route.handler) ? 'req fn' : 'req stub') : 'req' - - return Cypress.log({ - name: 'xhr', - displayName, - alias: route.alias, - aliasType: 'route', - type: 'parent', - event: true, - method: request.request.method, - timeout: undefined, - 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, - } - }, - }) - } - const { data: req, requestId, subscription } = frame const { routeId } = subscription const route = getRoute(routeId) const bodyParsed = parseJsonBody(req) + req.responseTimeout = Cypress.config('responseTimeout') + const reqClone = _.cloneDeep(req) + const subscribe = (eventName, handler) => { const subscription: Subscription = { id: _.uniqueId('Subscription'), @@ -94,27 +51,31 @@ export const onBeforeRequest: HandlerFn = (Cypre emitNetEvent('subscribe', { requestId, subscription } as NetEvent.ToServer.Subscribe) } - const getCanonicalRequest = (): Interception => { - const existingRequest = getRequest(routeId, requestId) + const getCanonicalInterception = (): Interception => { + const existingInterception = getRequest(routeId, requestId) - if (existingRequest) { - existingRequest.request = req + if (existingInterception) { + existingInterception.request = req - return existingRequest + return existingInterception } return { id: requestId, + browserRequestId: frame.browserRequestId, routeId, request: req, state: 'Received', requestWaited: false, responseWaited: false, subscriptions: [], + setLogFlag: () => { + throw new Error('default setLogFlag reached') + }, } } - const request: Interception = getCanonicalRequest() + const request: Interception = getCanonicalInterception() let resolved = false let handlerCompleted = false @@ -243,8 +204,6 @@ export const onBeforeRequest: HandlerFn = (Cypre // allow `req` to be sent outgoing, then pass the response body to `responseHandler` subscribe('response:callback', responseHandler) - userReq.responseTimeout = userReq.responseTimeout || Cypress.config('responseTimeout') - return finish(true) }, reply (responseHandler?, maybeBody?, maybeHeaders?) { @@ -274,6 +233,8 @@ export const onBeforeRequest: HandlerFn = (Cypre // `responseHandler` is a StaticResponse validateStaticResponse('req.reply', responseHandler) + request.setLogFlag('stubbed') + sendStaticResponse(requestId, responseHandler) return updateRequest(req) @@ -290,7 +251,7 @@ export const onBeforeRequest: HandlerFn = (Cypre destroy () { userReq.reply({ forceNetworkError: true, - }) // TODO: this misnomer is a holdover from XHR, should be numRequests + }) }, } @@ -301,7 +262,6 @@ export const onBeforeRequest: HandlerFn = (Cypre request.request = _.cloneDeep(req) request.state = 'Intercepted' - request.log && request.log.fireChangeEvent() } } @@ -325,6 +285,10 @@ export const onBeforeRequest: HandlerFn = (Cypre stringifyJsonBody(req) } + if (!_.isEqual(req, reqClone)) { + request.setLogFlag('reqModified') + } + resolve({ changedData: req, stopPropagation, @@ -337,9 +301,7 @@ export const onBeforeRequest: HandlerFn = (Cypre resolve = _resolve }) - if (!request.log) { - request.log = getRequestLog(route, request as Omit) - } + request.setLogFlag = Cypress.ProxyLogging.logInterception(request, route).setFlag // TODO: this misnomer is a holdover from XHR, should be numRequests route.log.set('numResponses', (route.log.get('numResponses') || 0) + 1) diff --git a/packages/driver/src/cy/net-stubbing/events/index.ts b/packages/driver/src/cy/net-stubbing/events/index.ts index 6e4cb4cb2b..671a1b3709 100644 --- a/packages/driver/src/cy/net-stubbing/events/index.ts +++ b/packages/driver/src/cy/net-stubbing/events/index.ts @@ -71,12 +71,12 @@ export function registerEvents (Cypress: Cypress.Cypress, cy: Cypress.cy) { state('aliasedRequests', []) }) - Cypress.on('net:event', (eventName, frame: NetEvent.ToDriver.Event) => { + Cypress.on('net:stubbing:event', (eventName, frame: NetEvent.ToDriver.Event) => { Bluebird.try(async () => { const handler = netEventHandlers[eventName] if (!handler) { - throw new Error(`received unknown net:event in driver: ${eventName}`) + throw new Error(`received unknown net:stubbing:event in driver: ${eventName}`) } const emitResolved = (result: HandlerResult) => { diff --git a/packages/driver/src/cy/net-stubbing/events/network-error.ts b/packages/driver/src/cy/net-stubbing/events/network-error.ts index cae33095ec..65bc3f6dd4 100644 --- a/packages/driver/src/cy/net-stubbing/events/network-error.ts +++ b/packages/driver/src/cy/net-stubbing/events/network-error.ts @@ -36,8 +36,6 @@ export const onNetworkError: HandlerFn = async (Cyp request.state = 'Errored' request.error = err - request.log.error(err) - if (isAwaitingResponse) { // the user is implicitly expecting there to be a successful response from the server, so fail the test // since a network error has occured diff --git a/packages/driver/src/cy/net-stubbing/events/response.ts b/packages/driver/src/cy/net-stubbing/events/response.ts index 405ea2c007..befc9d0add 100644 --- a/packages/driver/src/cy/net-stubbing/events/response.ts +++ b/packages/driver/src/cy/net-stubbing/events/response.ts @@ -20,6 +20,7 @@ export const onResponse: HandlerFn = async (Cyp const { data: res, requestId, subscription } = frame const { routeId } = subscription const request = getRequest(routeId, frame.requestId) + const resClone = _.cloneDeep(res) const bodyParsed = parseJsonBody(res) @@ -29,8 +30,6 @@ export const onResponse: HandlerFn = async (Cyp if (request) { request.state = 'ResponseReceived' - request.log.fireChangeEvent() - if (!userHandler) { // this is notification-only, update the request with the response attributes and end request.response = res @@ -41,9 +40,12 @@ export const onResponse: HandlerFn = async (Cyp const finishResponseStage = (res) => { if (request) { + if (!_.isEqual(resClone, res)) { + request.setLogFlag('resModified') + } + request.response = _.cloneDeep(res) request.state = 'ResponseIntercepted' - request.log.fireChangeEvent() } } diff --git a/packages/driver/src/cy/net-stubbing/events/utils.ts b/packages/driver/src/cy/net-stubbing/events/utils.ts index 5e05768728..339d0bd082 100644 --- a/packages/driver/src/cy/net-stubbing/events/utils.ts +++ b/packages/driver/src/cy/net-stubbing/events/utils.ts @@ -1,9 +1,13 @@ import { find } from 'lodash' import { CyHttpMessages } from '@packages/net-stubbing/lib/types' -export function hasJsonContentType (headers: { [k: string]: string }) { +export function hasJsonContentType (headers: { [k: string]: string | string[] }) { const contentType = find(headers, (v, k) => /^content-type$/i.test(k)) + if (Array.isArray(contentType)) { + return false + } + return contentType && /^application\/.*json/i.test(contentType) } diff --git a/packages/driver/src/cypress.js b/packages/driver/src/cypress.js index bbcabfa0a7..f6bde8ab70 100644 --- a/packages/driver/src/cypress.js +++ b/packages/driver/src/cypress.js @@ -19,6 +19,7 @@ const $SetterGetter = require('./cypress/setter_getter') const $Log = require('./cypress/log') const $Location = require('./cypress/location') const $LocalStorage = require('./cypress/local_storage') +const { ProxyLogging } = require('./cypress/proxy-logging') const $Mocha = require('./cypress/mocha') const $Mouse = require('./cy/mouse') const $Runner = require('./cypress/runner') @@ -152,6 +153,8 @@ class $Cypress { this.Cookies = $Cookies.create(config.namespace, d) + this.ProxyLogging = new ProxyLogging(this) + return this.action('cypress:config', config) } diff --git a/packages/driver/src/cypress/log.js b/packages/driver/src/cypress/log.js index ec93090451..8fca1779c2 100644 --- a/packages/driver/src/cypress/log.js +++ b/packages/driver/src/cypress/log.js @@ -303,16 +303,6 @@ const Log = function (cy, state, config, obj) { return _.pick(attributes, args) }, - publicInterface () { - return { - get: _.bind(this.get, this), - on: _.bind(this.on, this), - off: _.bind(this.off, this), - pick: _.bind(this.pick, this), - attributes, - } - }, - snapshot (name, options = {}) { // bail early and don't snapshot if we're in headless mode // or we're not storing tests diff --git a/packages/driver/src/cypress/proxy-logging.ts b/packages/driver/src/cypress/proxy-logging.ts new file mode 100644 index 0000000000..34dac030a2 --- /dev/null +++ b/packages/driver/src/cypress/proxy-logging.ts @@ -0,0 +1,360 @@ +import { Interception, Route } from '@packages/net-stubbing/lib/types' +import { BrowserPreRequest, BrowserResponseReceived, RequestError } from '@packages/proxy/lib/types' +import { makeErrFromObj } from './error_utils' +import Debug from 'debug' + +const debug = Debug('cypress:driver:proxy-logging') + +/** + * Remove and return the first element from `array` for which `filterFn` returns a truthy value. + */ +function take (array: E[], filterFn: (data: E) => boolean) { + for (const i in array) { + const e = array[i] + + if (!filterFn(e)) continue + + array.splice(i as unknown as number, 1) + + return e + } + + return +} + +function formatInterception ({ route, interception }: ProxyRequest['interceptions'][number]) { + const ret = { + 'RouteMatcher': route.options, + 'RouteHandler Type': !_.isNil(route.handler) ? (_.isFunction(route.handler) ? 'Function' : 'StaticResponse stub') : 'Spy', + 'RouteHandler': route.handler, + 'Request': interception.request, + } + + if (interception.response) { + ret['Response'] = _.omitBy(interception.response, _.isNil) + } + + const alias = interception.request.alias || route.alias + + if (alias) ret['Alias'] = alias + + return ret +} + +function getDisplayUrl (url: string) { + if (url.startsWith(window.location.origin)) { + return url.slice(window.location.origin.length) + } + + return url +} + +function getDynamicRequestLogConfig (req: Omit): Partial { + const last = _.last(req.interceptions) + let alias = last ? last.interception.request.alias || last.route.alias : undefined + + if (!alias && req.xhr && req.route) { + alias = req.route.alias + } + + return { + alias, + aliasType: alias ? 'route' : undefined, + } +} + +function getRequestLogConfig (req: Omit): Partial { + function getStatus (): string | undefined { + const { stubbed, reqModified, resModified } = req.flags + + if (stubbed) return + + if (reqModified && resModified) return 'req + res modified' + + if (reqModified) return 'req modified' + + if (resModified) return 'res modified' + + return + } + + return { + ...getDynamicRequestLogConfig(req), + displayName: req.preRequest.resourceType, + name: 'request', + type: 'parent', + event: true, + url: req.preRequest.url, + method: req.preRequest.method, + timeout: 0, + consoleProps: () => { + // high-level request information + const consoleProps = { + 'Resource Type': req.preRequest.resourceType, + Method: req.preRequest.method, + URL: req.preRequest.url, + 'Request went to origin?': req.flags.stubbed ? 'no (response was stubbed, see below)' : 'yes', + } + + if (req.flags.reqModified) consoleProps['Request modified?'] = 'yes' + + if (req.flags.resModified) consoleProps['Response modified?'] = 'yes' + + // details on matched XHR/intercept + if (req.xhr) consoleProps['XHR'] = req.xhr.xhr + + if (req.interceptions.length) { + if (req.interceptions.length > 1) { + consoleProps['Matched `cy.intercept()`s'] = req.interceptions.map(formatInterception) + } else { + consoleProps['Matched `cy.intercept()`'] = formatInterception(req.interceptions[0]) + } + } + + if (req.stack) { + consoleProps['groups'] = () => { + return [ + { + name: 'Initiator', + items: [req.stack], + label: false, + }, + ] + } + } + + // details on request/response/errors + consoleProps['Request Headers'] = req.preRequest.headers + + if (req.responseReceived) { + _.assign(consoleProps, { + 'Response Status Code': req.responseReceived.status, + 'Response Headers': req.responseReceived.headers, + }) + } + + let resBody + + if (req.xhr) { + consoleProps['Response Body'] = req.xhr.responseBody + } else if ((resBody = _.chain(req.interceptions).last().get('interception.response.body').value())) { + consoleProps['Response Body'] = resBody + } + + if (req.error) { + consoleProps['Error'] = req.error + } + + return consoleProps + }, + renderProps: () => { + function getIndicator (): 'aborted' | 'pending' | 'successful' | 'bad' { + if (!req.responseReceived) { + return 'pending' + } + + if (req.responseReceived.status >= 200 && req.responseReceived.status <= 299) { + return 'successful' + } + + return 'bad' + } + + const message = _.compact([ + req.preRequest.method, + req.responseReceived && req.responseReceived.status, + getDisplayUrl(req.preRequest.url), + ]).join(' ') + + return { + indicator: getIndicator(), + message, + status: getStatus(), + wentToOrigin: !req.flags.stubbed, + interceptions: [ + ...(req.interceptions.map(({ interception, route }) => { + return { + command: 'intercept', + alias: interception.request.alias || route.alias, + type: !_.isNil(route.handler) ? (_.isFunction(route.handler) ? 'function' : 'stub') : 'spy', + } + })), + ...(req.route ? [{ + command: 'route', + alias: req.route?.alias, + type: _.isNil(req.route?.response) ? 'spy' : 'stub', + }] : []), + ], + } + }, + } +} + +function shouldLog (preRequest: BrowserPreRequest) { + return ['xhr', 'fetch'].includes(preRequest.resourceType) +} + +class ProxyRequest { + log?: Cypress.Log + preRequest: BrowserPreRequest + responseReceived?: BrowserResponseReceived + error?: Error + xhr?: Cypress.WaitXHR + route?: any + stack?: string + interceptions: Array<{ interception: Interception, route: Route }> = [] + displayInterceptions: Array<{ command: 'intercept' | 'route', alias?: string, type: 'stub' | 'spy' | 'function' }> = [] + flags: { + spied?: boolean + stubbed?: boolean + reqModified?: boolean + resModified?: boolean + } = {} + + constructor (preRequest: BrowserPreRequest, opts?: Partial) { + this.preRequest = preRequest + opts && _.assign(this, opts) + } + + setFlag = (flag: keyof ProxyRequest['flags']) => { + this.flags[flag] = true + this.log?.set({}) + } +} + +type UnmatchedXhrLog = { + xhr: Cypress.WaitXHR + route?: any + log: Cypress.Log + stack?: string +} + +export class ProxyLogging { + unloggedPreRequests: Array = [] + unmatchedXhrLogs: Array = [] + proxyRequests: Array = [] + + constructor (private Cypress: Cypress.Cypress) { + Cypress.on('request:event', (eventName, data) => { + switch (eventName) { + case 'incoming:request': + return this.logIncomingRequest(data) + case 'response:received': + return this.updateRequestWithResponse(data) + case 'request:error': + return this.updateRequestWithError(data) + default: + throw new Error(`unrecognized request:event event ${eventName}`) + } + }) + + Cypress.on('test:before:run', () => { + for (const proxyRequest of this.proxyRequests) { + if (!proxyRequest.responseReceived && proxyRequest.log) { + proxyRequest.log.end() + } + } + this.unloggedPreRequests = [] + this.proxyRequests = [] + this.unmatchedXhrLogs = [] + }) + } + + /** + * The `cy.route()` XHR stub functions will log before a proxy log is received, so this queues an XHR log to be overridden by a proxy log later. + */ + addXhrLog (xhrLog: UnmatchedXhrLog) { + this.unmatchedXhrLogs.push(xhrLog) + } + + /** + * Update an existing proxy log with an interception, or create a new log if one was not created (like if shouldLog returned false) + */ + logInterception (interception: Interception, route: Route): ProxyRequest | undefined { + const unloggedPreRequest = take(this.unloggedPreRequests, ({ requestId }) => requestId === interception.browserRequestId) + + if (unloggedPreRequest) { + debug('interception matched an unlogged prerequest, logging %o', { unloggedPreRequest, interception }) + this.createProxyRequestLog(unloggedPreRequest) + } + + const proxyRequest = _.find(this.proxyRequests, ({ preRequest }) => preRequest.requestId === interception.browserRequestId) + + if (!proxyRequest) { + throw new Error(`Missing pre-request/proxy log for cy.intercept to ${interception.request.url}`) + } + + proxyRequest.interceptions.push({ interception, route }) + + proxyRequest.log?.set(getDynamicRequestLogConfig(proxyRequest)) + + // consider a function to be 'spying' until it actually stubs/modifies the response + proxyRequest.setFlag(!_.isNil(route.handler) && !_.isFunction(route.handler) ? 'stubbed' : 'spied') + + return proxyRequest + } + + private updateRequestWithResponse (responseReceived: BrowserResponseReceived): void { + const proxyRequest = _.find(this.proxyRequests, ({ preRequest }) => preRequest.requestId === responseReceived.requestId) + + if (!proxyRequest) { + return debug('unmatched responseReceived event %o', responseReceived) + } + + proxyRequest.responseReceived = responseReceived + proxyRequest.log?.snapshot('response').end() + } + + private updateRequestWithError (error: RequestError): void { + const proxyRequest = _.find(this.proxyRequests, ({ preRequest }) => preRequest.requestId === error.requestId) + + if (!proxyRequest) { + return debug('unmatched error event %o', error) + } + + proxyRequest.error = makeErrFromObj(error.error) + proxyRequest.log?.snapshot('error').error(proxyRequest.error) + } + + /** + * Create a Cypress.Log for an incoming proxy request, or store the metadata for later if it is ignored. + */ + private logIncomingRequest (preRequest: BrowserPreRequest): void { + // if this is an XHR, check to see if it matches an XHR log that is missing a pre-request + if (preRequest.resourceType === 'xhr') { + const unmatchedXhrLog = take(this.unmatchedXhrLogs, ({ xhr }) => xhr.url === preRequest.url && xhr.method === preRequest.method) + + if (unmatchedXhrLog) { + const { log, route } = unmatchedXhrLog + const proxyRequest = new ProxyRequest(preRequest, unmatchedXhrLog) + + if (route) { + proxyRequest.setFlag(_.isNil(route.response) ? 'spied' : 'stubbed') + } + + log.set(getRequestLogConfig(proxyRequest)) + + this.proxyRequests.push(proxyRequest) + + return + } + } + + if (!shouldLog(preRequest)) { + this.unloggedPreRequests.push(preRequest) + + return + } + + this.createProxyRequestLog(preRequest) + } + + private createProxyRequestLog (preRequest: BrowserPreRequest) { + const proxyRequest = new ProxyRequest(preRequest) + const logConfig = getRequestLogConfig(proxyRequest as Omit) + + proxyRequest.log = this.Cypress.log(logConfig).snapshot('request') + + this.proxyRequests.push(proxyRequest as ProxyRequest) + } +} diff --git a/packages/driver/types/internal-types.d.ts b/packages/driver/types/internal-types.d.ts index 008740e6b8..ef3a0936c8 100644 --- a/packages/driver/types/internal-types.d.ts +++ b/packages/driver/types/internal-types.d.ts @@ -3,7 +3,8 @@ declare namespace Cypress { interface Actions { - (action: 'net:event', frame: any) + (action: 'net:stubbing:event', frame: any) + (action: 'request:event', data: any) } interface cy { @@ -19,6 +20,8 @@ declare namespace Cypress { interface Cypress { backend: (eventName: string, ...args: any[]) => Promise + // TODO: how to pull this from proxy-logging.ts? can't import in a d.ts file... + ProxyLogging: any // TODO: how to pull these from resolvers.ts? can't import in a d.ts file... resolveWindowReference: any resolveLocationReference: any @@ -44,6 +47,7 @@ declare namespace Cypress { isStubbed?: boolean alias?: string aliasType?: 'route' + commandName?: string type?: 'parent' event?: boolean method?: string @@ -55,6 +59,7 @@ declare namespace Cypress { indicator?: 'aborted' | 'pending' | 'successful' | 'bad' message?: string } + browserPreRequest?: any } interface State { diff --git a/packages/net-stubbing/lib/external-types.ts b/packages/net-stubbing/lib/external-types.ts index cbd2222b29..95be51c57a 100644 --- a/packages/net-stubbing/lib/external-types.ts +++ b/packages/net-stubbing/lib/external-types.ts @@ -267,9 +267,11 @@ interface RequestEvents { */ export interface Interception { id: string + /* @internal */ + browserRequestId?: string routeId: string /* @internal */ - log?: any + setLogFlag: (flag: 'spied' | 'stubbed' | 'reqModified' | 'resModified') => void request: CyHttpMessages.IncomingRequest /** * Was `cy.wait()` used to wait on this request? diff --git a/packages/net-stubbing/lib/internal-types.ts b/packages/net-stubbing/lib/internal-types.ts index dc95317f47..db25aff740 100644 --- a/packages/net-stubbing/lib/internal-types.ts +++ b/packages/net-stubbing/lib/internal-types.ts @@ -59,13 +59,15 @@ export declare namespace NetEvent { export namespace ToDriver { export interface Event extends Http { + /** + * If set, this is the browser's internal identifier for this request. + */ + browserRequestId?: string subscription: Subscription eventId: string data: D } - export interface Request extends Event {} - export interface Response extends Event {} } diff --git a/packages/net-stubbing/lib/server/driver-events.ts b/packages/net-stubbing/lib/server/driver-events.ts index aed1f378c5..db3a98050b 100644 --- a/packages/net-stubbing/lib/server/driver-events.ts +++ b/packages/net-stubbing/lib/server/driver-events.ts @@ -106,7 +106,7 @@ export function _restoreMatcherOptionsTypes (options: AnnotatedRouteMatcherOptio return ret } -type OnNetEventOpts = { +type OnNetStubbingEventOpts = { eventName: string state: NetStubbingState socket: CyServer.Socket @@ -115,7 +115,7 @@ type OnNetEventOpts = { frame: NetEvent.ToServer.AddRoute | NetEvent.ToServer.EventHandlerResolved | NetEvent.ToServer.Subscribe | NetEvent.ToServer.SendStaticResponse } -export async function onNetEvent (opts: OnNetEventOpts): Promise { +export async function onNetStubbingEvent (opts: OnNetStubbingEventOpts): Promise { const { state, getFixture, args, eventName, frame } = opts debug('received driver event %o', { eventName, args }) diff --git a/packages/net-stubbing/lib/server/index.ts b/packages/net-stubbing/lib/server/index.ts index 8a9f97277e..c0383655b1 100644 --- a/packages/net-stubbing/lib/server/index.ts +++ b/packages/net-stubbing/lib/server/index.ts @@ -1,4 +1,4 @@ -export { onNetEvent } from './driver-events' +export { onNetStubbingEvent } from './driver-events' export { InterceptError } from './middleware/error' diff --git a/packages/net-stubbing/lib/server/intercepted-request.ts b/packages/net-stubbing/lib/server/intercepted-request.ts index bfea93eaee..3c00b779cc 100644 --- a/packages/net-stubbing/lib/server/intercepted-request.ts +++ b/packages/net-stubbing/lib/server/intercepted-request.ts @@ -148,6 +148,7 @@ export class InterceptedRequest { const eventFrame: NetEvent.ToDriver.Event = { eventId, subscription, + browserRequestId: this.req.browserPreRequest && this.req.browserPreRequest.requestId, requestId: this.id, data, } diff --git a/packages/net-stubbing/lib/server/util.ts b/packages/net-stubbing/lib/server/util.ts index b8817005fa..72dffffac2 100644 --- a/packages/net-stubbing/lib/server/util.ts +++ b/packages/net-stubbing/lib/server/util.ts @@ -29,7 +29,7 @@ export function emit (socket: CyServer.Socket, eventName: string, data: object) debug('sending event to driver %o', { eventName, data: _.chain(data).cloneDeep().omit('res.body').value() }) } - socket.toDriver('net:event', eventName, data) + socket.toDriver('net:stubbing:event', eventName, data) } export function getAllStringMatcherFields (options: RouteMatcherOptionsGeneric) { diff --git a/packages/net-stubbing/test/unit/intercepted-request-spec.ts b/packages/net-stubbing/test/unit/intercepted-request-spec.ts index 9070920dc5..ab062376ec 100644 --- a/packages/net-stubbing/test/unit/intercepted-request-spec.ts +++ b/packages/net-stubbing/test/unit/intercepted-request-spec.ts @@ -12,6 +12,8 @@ describe('InterceptedRequest', () => { } const state = NetStubbingState() const interceptedRequest = new InterceptedRequest({ + // @ts-ignore + req: {}, state, socket, matchingRoutes: [ @@ -39,7 +41,7 @@ describe('InterceptedRequest', () => { const data = { foo: 'bar' } socket.toDriver.callsFake((eventName, subEventName, frame) => { - expect(eventName).to.eq('net:event') + expect(eventName).to.eq('net:stubbing:event') expect(subEventName).to.eq('before:request') expect(frame).to.deep.include({ subscription: { diff --git a/packages/proxy/lib/http/error-middleware.ts b/packages/proxy/lib/http/error-middleware.ts index 78595ac3de..dac3f0a3f7 100644 --- a/packages/proxy/lib/http/error-middleware.ts +++ b/packages/proxy/lib/http/error-middleware.ts @@ -3,6 +3,7 @@ import { HttpMiddleware } from '.' import { InterceptError } from '@packages/net-stubbing' import { Readable } from 'stream' import { Request } from '@cypress/request' +import errors from '@packages/server/lib/errors' const debug = debugModule('cypress:proxy:http:error-middleware') @@ -22,6 +23,17 @@ const LogError: ErrorMiddleware = function () { this.next() } +const SendToDriver: ErrorMiddleware = function () { + if (this.req.browserPreRequest) { + this.socket.toDriver('request:event', 'request:error', { + requestId: this.req.browserPreRequest.requestId, + error: errors.clone(this.error), + }) + } + + this.next() +} + export const AbortRequest: ErrorMiddleware = function () { if (this.outgoingReq) { debug('aborting outgoingReq') @@ -47,6 +59,7 @@ export const DestroyResponse: ErrorMiddleware = function () { export default { LogError, + SendToDriver, InterceptError, AbortRequest, UnpipeResponse, diff --git a/packages/proxy/lib/http/index.ts b/packages/proxy/lib/http/index.ts index 665ec59f7c..80fbe4c679 100644 --- a/packages/proxy/lib/http/index.ts +++ b/packages/proxy/lib/http/index.ts @@ -176,6 +176,16 @@ export function _runStage (type: HttpStages, ctx: any, onError) { return runMiddlewareStack() } +function getUniqueRequestId (requestId: string) { + const match = /^(.*)-retry-([\d]+)$/.exec(requestId) + + if (match) { + return `${match[1]}-retry-${Number(match[2]) + 1}` + } + + return `${requestId}-retry-1` +} + export class Http { buffers: HttpBuffers config: CyServer.Config @@ -237,9 +247,14 @@ export class Http { const onError = () => { if (ctx.req.browserPreRequest) { // browsers will retry requests in the event of network errors, but they will not send pre-requests, - // so try to re-use the current browserPreRequest for the next retry - ctx.debug('Re-using pre-request data %o', ctx.req.browserPreRequest) - this.addPendingBrowserPreRequest(ctx.req.browserPreRequest) + // so try to re-use the current browserPreRequest for the next retry after incrementing the ID. + const preRequest = { + ...ctx.req.browserPreRequest, + requestId: getUniqueRequestId(ctx.req.browserPreRequest.requestId), + } + + ctx.debug('Re-using pre-request data %o', preRequest) + this.addPendingBrowserPreRequest(preRequest) } } @@ -269,7 +284,6 @@ export class Http { reset () { this.buffers.reset() - this.preRequests = new PreRequests() } setBuffer (buffer) { diff --git a/packages/proxy/lib/http/request-middleware.ts b/packages/proxy/lib/http/request-middleware.ts index 1b6452a1f2..e10c8b19fd 100644 --- a/packages/proxy/lib/http/request-middleware.ts +++ b/packages/proxy/lib/http/request-middleware.ts @@ -27,6 +27,25 @@ const CorrelateBrowserPreRequest: RequestMiddleware = async function () { if (this.req.headers['x-cypress-resolving-url']) { this.debug('skipping prerequest for resolve:url') delete this.req.headers['x-cypress-resolving-url'] + const requestId = `cy.visit-${Date.now()}` + + this.req.browserPreRequest = { + requestId, + method: this.req.method, + url: this.req.proxiedUrl, + // @ts-ignore + headers: this.req.headers, + resourceType: 'document', + originalResourceType: 'document', + } + + this.res.on('close', () => { + this.socket.toDriver('request:event', 'response:received', { + requestId, + headers: this.res.getHeaders(), + status: this.res.statusCode, + }) + }) return this.next() } @@ -42,7 +61,7 @@ const SendToDriver: RequestMiddleware = function () { const { browserPreRequest } = this.req if (browserPreRequest) { - this.socket.toDriver('proxy:incoming:request', browserPreRequest) + this.socket.toDriver('request:event', 'incoming:request', browserPreRequest) } this.next() @@ -167,9 +186,9 @@ const SendRequestOutgoing: RequestMiddleware = function () { export default { LogRequest, + MaybeEndRequestWithBufferedResponse, CorrelateBrowserPreRequest, SendToDriver, - MaybeEndRequestWithBufferedResponse, InterceptRequest, RedirectToClientRouteIfUnloaded, EndRequestsToBlockedHosts, diff --git a/packages/proxy/lib/types.ts b/packages/proxy/lib/types.ts index 46a42b9c42..80a0416c5c 100644 --- a/packages/proxy/lib/types.ts +++ b/packages/proxy/lib/types.ts @@ -30,7 +30,7 @@ export { RequestMiddleware } from './http/request-middleware' export { ResponseMiddleware } from './http/response-middleware' -export type ResourceType = 'fetch' | 'xhr' | 'websocket' | 'stylesheet' | 'script' | 'image' | 'font' | 'cspviolationreport' | 'ping' | 'manifest' | 'other' +export type ResourceType = 'document' | 'fetch' | 'xhr' | 'websocket' | 'stylesheet' | 'script' | 'image' | 'font' | 'cspviolationreport' | 'ping' | 'manifest' | 'other' /** * Metadata about an HTTP request, according to the browser's pre-request event. @@ -39,6 +39,21 @@ export type BrowserPreRequest = { requestId: string method: string url: string + headers: { [key: string]: string | string[] } resourceType: ResourceType originalResourceType: string | undefined } + +/** + * Notification that the browser has received a response for a request for which a pre-request may have been emitted. + */ +export type BrowserResponseReceived = { + requestId: string + status: number + headers: { [key: string]: string | string[] } +} + +export type RequestError = { + requestId: string + error: any +} diff --git a/packages/proxy/test/integration/net-stubbing.spec.ts b/packages/proxy/test/integration/net-stubbing.spec.ts index 73b5d77e44..eee1092c9b 100644 --- a/packages/proxy/test/integration/net-stubbing.spec.ts +++ b/packages/proxy/test/integration/net-stubbing.spec.ts @@ -2,7 +2,7 @@ import { NetworkProxy } from '../../' import { netStubbingState as _netStubbingState, NetStubbingState, - onNetEvent, + onNetStubbingEvent, } from '@packages/net-stubbing' import { defaultMiddleware } from '../../lib/http' import express from 'express' @@ -166,7 +166,7 @@ context('network stubbing', () => { socket.toDriver.callsFake((_, event, data) => { if (event === 'before:request') { - onNetEvent({ + onNetStubbingEvent({ eventName: 'send:static:response', // @ts-ignore frame: { @@ -234,7 +234,7 @@ context('network stubbing', () => { socket.toDriver.callsFake((_, event, data) => { if (event === 'before:request') { sendContentLength = data.data.headers['content-length'] - onNetEvent({ + onNetStubbingEvent({ eventName: 'send:static:response', // @ts-ignore frame: { diff --git a/packages/proxy/test/unit/http/error-middleware.spec.ts b/packages/proxy/test/unit/http/error-middleware.spec.ts index ac195671d0..c96f2d37b9 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', + 'SendToDriver', 'InterceptError', 'AbortRequest', 'UnpipeResponse', diff --git a/packages/proxy/test/unit/http/request-middleware.spec.ts b/packages/proxy/test/unit/http/request-middleware.spec.ts index bdd1395494..1bde606e5c 100644 --- a/packages/proxy/test/unit/http/request-middleware.spec.ts +++ b/packages/proxy/test/unit/http/request-middleware.spec.ts @@ -6,9 +6,9 @@ describe('http/request-middleware', function () { it('exports the members in the correct order', function () { expect(_.keys(RequestMiddleware)).to.have.ordered.members([ 'LogRequest', + 'MaybeEndRequestWithBufferedResponse', 'CorrelateBrowserPreRequest', 'SendToDriver', - 'MaybeEndRequestWithBufferedResponse', 'InterceptRequest', 'RedirectToClientRouteIfUnloaded', 'EndRequestsToBlockedHosts', diff --git a/packages/reporter/cypress/fixtures/runnables_commands.json b/packages/reporter/cypress/fixtures/runnables_commands.json index 285622f5bf..63fd46446a 100644 --- a/packages/reporter/cypress/fixtures/runnables_commands.json +++ b/packages/reporter/cypress/fixtures/runnables_commands.json @@ -113,8 +113,7 @@ "event": true, "testId": "r3", "timeout": 4000, - "type": "parent", - "alias": "dup0" + "type": "parent" }, { "hookId": "r3", @@ -127,8 +126,7 @@ "event": true, "testId": "r3", "timeout": 4000, - "type": "parent", - "alias": "dup1" + "type": "parent" }, { "hookId": "r3", diff --git a/packages/reporter/cypress/integration/aliases_spec.ts b/packages/reporter/cypress/integration/aliases_spec.ts index 3187e077cc..94a3d0af5a 100644 --- a/packages/reporter/cypress/integration/aliases_spec.ts +++ b/packages/reporter/cypress/integration/aliases_spec.ts @@ -30,16 +30,122 @@ describe('aliases', () => { }) }) + context('interceptions + status', () => { + it('shows only status if no alias or dupe', () => { + addCommand(runner, { + aliasType: 'route', + renderProps: { + wentToOrigin: true, + status: 'some status', + interceptions: [{ + type: 'spy', + command: 'intercept', + }], + }, + }) + + cy.contains('.command-number', '1').parent().find('.command-interceptions') + .should('have.text', 'some status no alias') + .trigger('mouseover') + .get('.cy-tooltip').should('have.text', 'This request matched:cy.intercept() spy with no alias') + .percySnapshot() + }) + + it('shows status and count if dupe', () => { + addCommand(runner, { + aliasType: 'route', + renderProps: { + wentToOrigin: true, + status: 'some status', + interceptions: [{ + type: 'spy', + command: 'intercept', + }, { + type: 'spy', + command: 'route', + }], + }, + }) + + cy.contains('.command-number', '1').parent().find('.command-interceptions') + .should('have.text', 'some status no alias') + .parent().find('.command-interceptions-count') + .should('have.text', '2') + .trigger('mouseover') + .get('.cy-tooltip').should('have.text', 'This request matched:cy.intercept() spy with no aliascy.route() spy with no alias') + .percySnapshot() + }) + + it('shows status and alias and count if dupe', () => { + addCommand(runner, { + aliasType: 'route', + alias: 'myAlias', + renderProps: { + wentToOrigin: true, + status: 'some status', + interceptions: [{ + type: 'spy', + command: 'intercept', + alias: 'firstAlias', + }, { + type: 'spy', + command: 'intercept', + alias: 'myAlias', + }], + }, + }) + + cy.contains('.command-number', '1').parent().find('.command-interceptions') + .should('have.text', 'some status myAlias') + .parent().find('.command-interceptions-count') + .should('have.text', '2') + .trigger('mouseover') + .get('.cy-tooltip').should('have.text', 'This request matched:cy.intercept() spy with alias @firstAliascy.intercept() spy with alias @myAlias') + .percySnapshot() + }) + + it('shows status and alias', () => { + addCommand(runner, { + aliasType: 'route', + alias: 'myAlias', + renderProps: { + wentToOrigin: true, + status: 'some status', + interceptions: [{ + type: 'spy', + command: 'intercept', + alias: 'myAlias', + }], + }, + }) + + cy.contains('.command-number', '1').parent().find('.command-interceptions') + .should('have.text', 'some status myAlias') + .trigger('mouseover') + .get('.cy-tooltip').should('have.text', 'This request matched:cy.intercept() spy with alias @myAlias') + .percySnapshot() + }) + }) + context('route aliases', () => { describe('without duplicates', () => { beforeEach(() => { addCommand(runner, { alias: 'getUsers', aliasType: 'route', - displayName: 'xhr stub', + displayName: 'xhr', event: true, name: 'xhr', - renderProps: { message: 'GET --- /users', indicator: 'passed' }, + renderProps: { + message: 'GET --- /users', + indicator: 'passed', + wentToOrigin: false, + interceptions: [{ + type: 'stub', + command: 'route', + alias: 'getUsers', + }], + }, }) addCommand(runner, { @@ -84,19 +190,21 @@ describe('aliases', () => { addCommand(runner, { alias: 'getPosts', aliasType: 'route', - displayName: 'xhr stub', + displayName: 'xhr', event: true, name: 'xhr', - renderProps: { message: 'GET --- /posts', indicator: 'passed' }, + // @ts-ignore + renderProps: { message: 'GET --- /posts', indicator: 'passed', interceptions: [{ alias: 'getPosts' }] }, }) addCommand(runner, { alias: 'getPosts', aliasType: 'route', - displayName: 'xhr stub', + displayName: 'xhr', event: true, name: 'xhr', - renderProps: { message: 'GET --- /posts', indicator: 'passed' }, + // @ts-ignore + renderProps: { message: 'GET --- /posts', indicator: 'passed', interceptions: [{ alias: 'getPosts' }] }, }) addCommand(runner, { @@ -123,7 +231,7 @@ describe('aliases', () => { }) it('renders all aliases ', () => { - cy.get('.command-alias').should('have.length', 3) + cy.get('.command-alias').should('have.length', 2) cy.percySnapshot() }) @@ -161,7 +269,7 @@ describe('aliases', () => { .within(() => { cy.contains('.num-duplicates', '2') - cy.contains('.command-alias', 'getPosts') + cy.contains('.command-interceptions', 'getPosts') }) }) @@ -175,7 +283,7 @@ describe('aliases', () => { .within(() => { cy.get('.num-duplicates').should('not.be.visible') - cy.contains('.command-alias', 'getPosts') + cy.contains('.command-interceptions', 'getPosts') }) }) }) @@ -185,28 +293,55 @@ describe('aliases', () => { addCommand(runner, { alias: 'getPosts', aliasType: 'route', - displayName: 'xhr stub', + displayName: 'xhr', event: true, name: 'xhr', - renderProps: { message: 'GET --- /posts', indicator: 'passed' }, + renderProps: { + message: 'GET --- /users', + indicator: 'passed', + wentToOrigin: false, + interceptions: [{ + type: 'stub', + command: 'route', + alias: 'getUsers', + }], + }, }) addCommand(runner, { alias: 'getUsers', aliasType: 'route', - displayName: 'xhr stub', + displayName: 'xhr', event: true, name: 'xhr', - renderProps: { message: 'GET --- /users', indicator: 'passed' }, + renderProps: { + message: 'GET --- /users', + indicator: 'passed', + wentToOrigin: false, + interceptions: [{ + type: 'stub', + command: 'route', + alias: 'getUsers', + }], + }, }) addCommand(runner, { alias: 'getPosts', aliasType: 'route', - displayName: 'xhr stub', + displayName: 'xhr', event: true, name: 'xhr', - renderProps: { message: 'GET --- /posts', indicator: 'passed' }, + renderProps: { + message: 'GET --- /posts', + indicator: 'passed', + wentToOrigin: false, + interceptions: [{ + type: 'stub', + command: 'route', + alias: 'getPosts', + }], + }, }) addCommand(runner, { diff --git a/packages/reporter/cypress/integration/commands_spec.ts b/packages/reporter/cypress/integration/commands_spec.ts index 469e2b8df2..a79b2b3a00 100644 --- a/packages/reporter/cypress/integration/commands_spec.ts +++ b/packages/reporter/cypress/integration/commands_spec.ts @@ -228,11 +228,6 @@ describe('commands', () => { .should('have.text', '4') }) - it('displays names of duplicates', () => { - cy.contains('GET --- /dup').closest('.command').find('.command-alias') - .should('have.text', 'dup0, dup1') - }) - it('expands all events after clicking arrow', () => { cy.contains('GET --- /dup').closest('.command').find('.command-expander').click() cy.get('.command-name-xhr').should('have.length', 6) @@ -240,15 +235,6 @@ describe('commands', () => { .should('be.visible') .find('.command').should('have.length', 3) }) - - it('splits up duplicate names when expanded', () => { - cy.contains('GET --- /dup').closest('.command').as('cmd') - - cy.get('@cmd').find('.command-expander').click() - cy.get('@cmd').find('.command-alias').as('alias') - cy.get('@alias').its(0).should('have.text', 'dup0') - cy.get('@alias').its(1).should('have.text', 'dup1') - }) }) context('clicking', () => { diff --git a/packages/reporter/src/commands/command-model.ts b/packages/reporter/src/commands/command-model.ts index 376760f52e..8532b8fd46 100644 --- a/packages/reporter/src/commands/command-model.ts +++ b/packages/reporter/src/commands/command-model.ts @@ -9,6 +9,13 @@ const LONG_RUNNING_THRESHOLD = 1000 interface RenderProps { message?: string indicator?: string + interceptions?: Array<{ + command: 'intercept' | 'route' + alias?: string + type: 'function' | 'stub' | 'spy' + }> + status?: string + wentToOrigin?: boolean } export interface CommandProps extends InstrumentProps { diff --git a/packages/reporter/src/commands/command.tsx b/packages/reporter/src/commands/command.tsx index ef211f2937..e8f651e760 100644 --- a/packages/reporter/src/commands/command.tsx +++ b/packages/reporter/src/commands/command.tsx @@ -77,6 +77,43 @@ const AliasesReferences = observer(({ model, aliasesWithDuplicates }: AliasesRef )) +interface InterceptionsProps { + model: CommandModel +} + +const Interceptions = observer(({ model }: InterceptionsProps) => { + if (!model.renderProps.interceptions?.length) return null + + function getTitle () { + return ( + + {model.renderProps.wentToOrigin ? '' : <>This request did not go to origin because the response was stubbed.
} + This request matched: +
    + {model.renderProps.interceptions?.map(({ command, alias, type }, i) => { + return (
  • + cy.{command}() {type} with {alias ? <>alias @{alias} : 'no alias'} +
  • ) + })} +
+
+ ) + } + + const count = model.renderProps.interceptions.length + + const displayAlias = _.chain(model.renderProps.interceptions).last().get('alias').value() + + return ( + + + 1 && 'show-count')}>{model.renderProps.status ? {model.renderProps.status} : null}{displayAlias || no alias} + {count > 1 ? {count} : null} + + + ) +}) + interface AliasesProps { isOpen: boolean model: CommandModel @@ -84,7 +121,7 @@ interface AliasesProps { } const Aliases = observer(({ model, aliasesWithDuplicates, isOpen }: AliasesProps) => { - if (!model.alias) return null + if (!model.alias || model.aliasType === 'route') return null return ( @@ -113,7 +150,11 @@ interface MessageProps { const Message = observer(({ model }: MessageProps) => ( - + { {model.numElements} + - 1 })}>{model.numDuplicates} + {model.numDuplicates} diff --git a/packages/reporter/src/commands/commands.scss b/packages/reporter/src/commands/commands.scss index 183738a4d3..0b8db508f8 100644 --- a/packages/reporter/src/commands/commands.scss +++ b/packages/reporter/src/commands/commands.scss @@ -54,8 +54,6 @@ } .command-is-event { - font-style: italic; - .command-method, .command-message { color: #9a9aaa !important; @@ -98,7 +96,16 @@ flex-wrap: wrap; padding: 2px 5px 0; - .command-alias { + .command-interceptions { + font-style: normal; + + .status { + font-weight: 600; + } + } + + .command-alias, + .command-interceptions { border-radius: 10px; color: #777888; padding: 0 5px; @@ -139,7 +146,8 @@ } .num-duplicates, - .command-alias-count { + .command-alias-count, + .command-interceptions-count { border-radius: 5px; color: #777; font-size: 90%; @@ -154,7 +162,8 @@ padding: 3px 5px 3px 5px; } - .num-duplicates.has-alias.has-duplicates { + .num-duplicates.has-alias.has-duplicates, + .command-interceptions-count { border-radius: 0 10px 10px 0; padding: 4px 5px 2px 3px; } @@ -165,7 +174,8 @@ } .num-duplicates, - .command-alias-count { + .command-alias-count, + .command-interceptions-count { background-color: darken(#ffdf9c, 8%) !important; } } @@ -439,7 +449,8 @@ display: none; } - .command-alias { + .command-alias, + .command-interceptions { font-family: $open-sans; font-size: 10px; line-height: 1.75; diff --git a/packages/reporter/src/lib/tooltip.scss b/packages/reporter/src/lib/tooltip.scss index 1316da5d1f..f7e74d69fd 100644 --- a/packages/reporter/src/lib/tooltip.scss +++ b/packages/reporter/src/lib/tooltip.scss @@ -16,4 +16,8 @@ $cy-tooltip-class: 'cy-tooltip'; box-shadow: 0 0.5px 0 1px rgb(95, 95, 95); font-family: monospace, monospace; } + + code { + font-size: .8em; + } } diff --git a/packages/runner-shared/src/event-manager.js b/packages/runner-shared/src/event-manager.js index da0cc621b0..ea228f6d8a 100644 --- a/packages/runner-shared/src/event-manager.js +++ b/packages/runner-shared/src/event-manager.js @@ -27,7 +27,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 script:error'.split(' ') +const socketToDriverEvents = 'net:stubbing:event request:event script:error'.split(' ') const localToReporterEvents = 'reporter:log:add reporter:log:state:changed reporter:log:remove'.split(' ') const localBus = new EventEmitter() diff --git a/packages/runner/cypress/fixtures/errors/intercept_spec.ts b/packages/runner/cypress/fixtures/errors/intercept_spec.ts index c46a259682..16a3deddd4 100644 --- a/packages/runner/cypress/fixtures/errors/intercept_spec.ts +++ b/packages/runner/cypress/fixtures/errors/intercept_spec.ts @@ -3,12 +3,23 @@ import './setup' describe('cy.intercept', () => { const { $ } = Cypress + const emitProxyLog = () => Cypress.emit('request:event', 'incoming:request', { + requestId: 1, + method: 'GET', + url: '', + headers: {}, + resourceType: 'other', + originalResourceType: 'other', + }) + it('assertion failure in req callback', () => { cy.intercept('/json-content-type', () => { expect('a').to.eq('b') }) .then(() => { - Cypress.emit('net:event', 'before:request', { + emitProxyLog() + Cypress.emit('net:stubbing:event', 'before:request', { + browserRequestId: 1, eventId: '1', subscription: { // @ts-ignore @@ -30,7 +41,9 @@ describe('cy.intercept', () => { }) }) .then(() => { - Cypress.emit('net:event', 'before:request', { + emitProxyLog() + Cypress.emit('net:stubbing:event', 'before:request', { + browserRequestId: 1, eventId: '1', requestId: '1', subscription: { @@ -43,7 +56,7 @@ describe('cy.intercept', () => { }, }) - Cypress.emit('net:event', 'before:response', { + Cypress.emit('net:stubbing:event', 'before:response', { eventId: '1', requestId: '1', subscription: { @@ -68,7 +81,9 @@ describe('cy.intercept', () => { }) }) .then(() => { - Cypress.emit('net:event', 'before:request', { + emitProxyLog() + Cypress.emit('net:stubbing:event', 'before:request', { + browserRequestId: 1, eventId: '1', requestId: '1', subscription: { @@ -81,7 +96,7 @@ describe('cy.intercept', () => { }, }) - Cypress.emit('net:event', 'network:error', { + Cypress.emit('net:stubbing:event', 'network:error', { eventId: '1', requestId: '1', subscription: { diff --git a/packages/server/lib/automation/automation.ts b/packages/server/lib/automation/automation.ts index b287aa496b..b7ec39c523 100644 --- a/packages/server/lib/automation/automation.ts +++ b/packages/server/lib/automation/automation.ts @@ -8,6 +8,8 @@ type NullableMiddlewareHook = (() => void) | null export type OnBrowserPreRequest = (browserPreRequest: BrowserPreRequest) => void +export type onRequestEvent = (eventName: string, data: any) => void + interface IMiddleware { onPush: NullableMiddlewareHook onBeforeRequest: NullableMiddlewareHook @@ -22,7 +24,7 @@ export class Automation { private cookies: Cookies private screenshot: { capture: (data: any, automate: any) => any } - constructor (cyNamespace?: string, cookieNamespace?: string, screenshotsFolder?: string | false, public onBrowserPreRequest?: OnBrowserPreRequest) { + constructor (cyNamespace?: string, cookieNamespace?: string, screenshotsFolder?: string | false, public onBrowserPreRequest?: OnBrowserPreRequest, public onRequestEvent?: onRequestEvent) { this.requests = {} // set the middleware diff --git a/packages/server/lib/browsers/cdp_automation.ts b/packages/server/lib/browsers/cdp_automation.ts index cf60073c16..7bd75de1ad 100644 --- a/packages/server/lib/browsers/cdp_automation.ts +++ b/packages/server/lib/browsers/cdp_automation.ts @@ -6,7 +6,7 @@ import cdp from 'devtools-protocol' import { cors } from '@packages/network' import debugModule from 'debug' import { Automation } from '../automation' -import { ResourceType, BrowserPreRequest } from '@packages/proxy' +import { ResourceType, BrowserPreRequest, BrowserResponseReceived } from '@packages/proxy' const debugVerbose = debugModule('cypress-verbose:server:browsers:cdp_automation') @@ -156,6 +156,7 @@ const ffToStandardResourceTypeMap: { [ff: string]: ResourceType } = { export class CdpAutomation { constructor (private sendDebuggerCommandFn: SendDebuggerCommand, onFn: OnFn, private automation: Automation) { onFn('Network.requestWillBeSent', this.onNetworkRequestWillBeSent) + onFn('Network.responseReceived', this.onResponseReceived) sendDebuggerCommandFn('Network.enable', { maxTotalBufferSize: 0, maxResourceBufferSize: 0, @@ -164,6 +165,7 @@ export class CdpAutomation { } private onNetworkRequestWillBeSent = (params: cdp.Network.RequestWillBeSentEvent) => { + debugVerbose('received networkRequestWillBeSent %o', params) let url = params.request.url // in Firefox, the hash is incorrectly included in the URL: https://bugzilla.mozilla.org/show_bug.cgi?id=1715366 @@ -175,6 +177,7 @@ export class CdpAutomation { requestId: params.requestId, method: params.request.method, url, + headers: params.request.headers, resourceType: normalizeResourceType(params.type), originalResourceType: params.type, } @@ -182,6 +185,16 @@ export class CdpAutomation { this.automation.onBrowserPreRequest?.(browserPreRequest) } + private onResponseReceived = (params: cdp.Network.ResponseReceivedEvent) => { + const browserResponseReceived: BrowserResponseReceived = { + requestId: params.requestId, + status: params.response.status, + headers: params.response.headers, + } + + this.automation.onRequestEvent?.('response:received', browserResponseReceived) + } + private getAllCookies = (filter: CyCookieFilter) => { return this.sendDebuggerCommandFn('Network.getAllCookies') .then((result: cdp.Network.GetAllCookiesResponse) => { diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 0009d0d6d9..71d676c458 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -573,7 +573,11 @@ export class ProjectBase extends EE { this.server.addBrowserPreRequest(browserPreRequest) } - this._automation = new Automation(namespace, socketIoCookie, screenshotsFolder, onBrowserPreRequest) + const onRequestEvent = (eventName, data) => { + this.server.emitRequestEvent(eventName, data) + } + + this._automation = new Automation(namespace, socketIoCookie, screenshotsFolder, onBrowserPreRequest, onRequestEvent) this.server.startWebsockets(this.automation, this.cfg, { onReloadBrowser: options.onReloadBrowser, diff --git a/packages/server/lib/server-base.ts b/packages/server/lib/server-base.ts index 1edd8c052b..40b1623b28 100644 --- a/packages/server/lib/server-base.ts +++ b/packages/server/lib/server-base.ts @@ -327,6 +327,10 @@ export abstract class ServerBase { this.networkProxy.addPendingBrowserPreRequest(browserPreRequest) } + emitRequestEvent (eventName, data) { + this.socket.toDriver('request:event', eventName, data) + } + _createHttpServer (app): DestroyableHttpServer { const svr = http.createServer(httpUtils.lenientOptions, app) diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index 81dc979b84..efc0f2fe99 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -1,7 +1,7 @@ import Bluebird from 'bluebird' import Debug from 'debug' import _ from 'lodash' -import { onNetEvent } from '@packages/net-stubbing' +import { onNetStubbingEvent } from '@packages/net-stubbing' import * as socketIo from '@packages/socket' import firefoxUtil from './browsers/firefox-util' import errors from './errors' @@ -373,7 +373,7 @@ export class SocketBase { case 'write:file': return files.writeFile(config.projectRoot, args[0], args[1], args[2]) case 'net': - return onNetEvent({ + return onNetStubbingEvent({ eventName: args[0], frame: args[1], state: options.netStubbingState,