feat: proxy logging (#16730)

Co-authored-by: Chris Breiding <chrisbreiding@users.noreply.github.com>
This commit is contained in:
Zach Bloomquist
2021-08-02 17:10:19 -04:00
committed by GitHub
parent 70e9c0f9cb
commit 2454c19afb
44 changed files with 1251 additions and 256 deletions

View File

@@ -5517,6 +5517,7 @@ declare namespace Cypress {
interface Log {
end(): Log
error(error: Error): Log
finish(): void
get<K extends keyof LogConfig>(attr: K): LogConfig[K]
get(): LogConfig

View File

@@ -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')

View File

@@ -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
}

View File

@@ -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)
},
))
})
})
})
})

View File

@@ -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',
})
})
})

View File

@@ -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()
}
},

View File

@@ -16,9 +16,6 @@ export const onAfterResponse: HandlerFn<CyHttpMessages.ResponseComplete> = async
request.state = 'Complete'
request.log.fireChangeEvent()
request.log.end()
// @ts-ignore
userHandler && await userHandler(request.response!)

View File

@@ -1,7 +1,6 @@
import _ from 'lodash'
import {
Route,
Interception,
CyHttpMessages,
SERIALIZABLE_REQ_PROPS,
@@ -24,58 +23,16 @@ type Result = HandlerResult<CyHttpMessages.IncomingRequest>
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<CyHttpMessages.IncomingRequest> = (Cypress, frame, userHandler, { getRoute, getRequest, emitNetEvent, sendStaticResponse }) => {
function getRequestLog (route: Route, request: Omit<Interception, 'log'>) {
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<CyHttpMessages.IncomingRequest> = (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<CyHttpMessages.IncomingRequest> = (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<CyHttpMessages.IncomingRequest> = (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<CyHttpMessages.IncomingRequest> = (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<CyHttpMessages.IncomingRequest> = (Cypre
request.request = _.cloneDeep(req)
request.state = 'Intercepted'
request.log && request.log.fireChangeEvent()
}
}
@@ -325,6 +285,10 @@ export const onBeforeRequest: HandlerFn<CyHttpMessages.IncomingRequest> = (Cypre
stringifyJsonBody(req)
}
if (!_.isEqual(req, reqClone)) {
request.setLogFlag('reqModified')
}
resolve({
changedData: req,
stopPropagation,
@@ -337,9 +301,7 @@ export const onBeforeRequest: HandlerFn<CyHttpMessages.IncomingRequest> = (Cypre
resolve = _resolve
})
if (!request.log) {
request.log = getRequestLog(route, request as Omit<Interception, 'log'>)
}
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)

View File

@@ -71,12 +71,12 @@ export function registerEvents (Cypress: Cypress.Cypress, cy: Cypress.cy) {
state('aliasedRequests', [])
})
Cypress.on('net:event', (eventName, frame: NetEvent.ToDriver.Event<any>) => {
Cypress.on('net:stubbing:event', (eventName, frame: NetEvent.ToDriver.Event<any>) => {
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<any>) => {

View File

@@ -36,8 +36,6 @@ export const onNetworkError: HandlerFn<CyHttpMessages.NetworkError> = 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

View File

@@ -20,6 +20,7 @@ export const onResponse: HandlerFn<CyHttpMessages.IncomingResponse> = 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<CyHttpMessages.IncomingResponse> = 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<CyHttpMessages.IncomingResponse> = async (Cyp
const finishResponseStage = (res) => {
if (request) {
if (!_.isEqual(resClone, res)) {
request.setLogFlag('resModified')
}
request.response = _.cloneDeep(res)
request.state = 'ResponseIntercepted'
request.log.fireChangeEvent()
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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<E> (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<ProxyRequest, 'log'>): Partial<Cypress.LogConfig> {
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<ProxyRequest, 'log'>): Partial<Cypress.LogConfig> {
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<ProxyRequest>) {
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<BrowserPreRequest> = []
unmatchedXhrLogs: Array<UnmatchedXhrLog> = []
proxyRequests: Array<ProxyRequest> = []
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'>)
proxyRequest.log = this.Cypress.log(logConfig).snapshot('request')
this.proxyRequests.push(proxyRequest as ProxyRequest)
}
}

View File

@@ -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<any>
// 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 {

View File

@@ -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?

View File

@@ -59,13 +59,15 @@ export declare namespace NetEvent {
export namespace ToDriver {
export interface Event<D> 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<CyHttpMessages.IncomingRequest> {}
export interface Response extends Event<CyHttpMessages.IncomingResponse> {}
}

View File

@@ -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<BackendStaticResponse> | NetEvent.ToServer.EventHandlerResolved | NetEvent.ToServer.Subscribe | NetEvent.ToServer.SendStaticResponse
}
export async function onNetEvent (opts: OnNetEventOpts): Promise<any> {
export async function onNetStubbingEvent (opts: OnNetStubbingEventOpts): Promise<any> {
const { state, getFixture, args, eventName, frame } = opts
debug('received driver event %o', { eventName, args })

View File

@@ -1,4 +1,4 @@
export { onNetEvent } from './driver-events'
export { onNetStubbingEvent } from './driver-events'
export { InterceptError } from './middleware/error'

View File

@@ -148,6 +148,7 @@ export class InterceptedRequest {
const eventFrame: NetEvent.ToDriver.Event<any> = {
eventId,
subscription,
browserRequestId: this.req.browserPreRequest && this.req.browserPreRequest.requestId,
requestId: this.id,
data,
}

View File

@@ -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<any>) {

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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: {

View File

@@ -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',

View File

@@ -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',

View File

@@ -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",

View File

@@ -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, {

View File

@@ -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', () => {

View File

@@ -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 {

View File

@@ -77,6 +77,43 @@ const AliasesReferences = observer(({ model, aliasesWithDuplicates }: AliasesRef
</span>
))
interface InterceptionsProps {
model: CommandModel
}
const Interceptions = observer(({ model }: InterceptionsProps) => {
if (!model.renderProps.interceptions?.length) return null
function getTitle () {
return (
<span>
{model.renderProps.wentToOrigin ? '' : <>This request did not go to origin because the response was stubbed.<br/></>}
This request matched:
<ul>
{model.renderProps.interceptions?.map(({ command, alias, type }, i) => {
return (<li key={i}>
<code>cy.{command}()</code> {type} with {alias ? <>alias <code>@{alias}</code></> : 'no alias'}
</li>)
})}
</ul>
</span>
)
}
const count = model.renderProps.interceptions.length
const displayAlias = _.chain(model.renderProps.interceptions).last().get('alias').value()
return (
<Tooltip placement='top' title={getTitle()} className='cy-tooltip'>
<span>
<span className={cs('command-interceptions', 'route', count > 1 && 'show-count')}>{model.renderProps.status ? <span className='status'>{model.renderProps.status} </span> : null}{displayAlias || <em className="no-alias">no alias</em>}</span>
{count > 1 ? <span className={'command-interceptions-count'}>{count}</span> : null}
</span>
</Tooltip>
)
})
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 (
<span>
@@ -113,7 +150,11 @@ interface MessageProps {
const Message = observer(({ model }: MessageProps) => (
<span>
<i className={`fas fa-circle ${model.renderProps.indicator}`} />
<i className={cs(
model.renderProps.wentToOrigin ? 'fas' : 'far',
'fa-circle',
model.renderProps.indicator,
)} />
<span
className='command-message-text'
dangerouslySetInnerHTML={{ __html: formattedMessage(model.displayMessage || '') }}
@@ -222,9 +263,10 @@ class Command extends Component<Props> {
<span className='num-elements'>{model.numElements}</span>
</Tooltip>
<span className='alias-container'>
<Interceptions model={model} />
<Aliases model={model} aliasesWithDuplicates={aliasesWithDuplicates} isOpen={this.isOpen} />
<Tooltip placement='top' title={`This event occurred ${model.numDuplicates} times`} className='cy-tooltip'>
<span className={cs('num-duplicates', { 'has-alias': model.alias, 'has-duplicates': model.numDuplicates > 1 })}>{model.numDuplicates}</span>
<span className={cs('num-duplicates', { 'has-alias': model.alias })}>{model.numDuplicates}</span>
</Tooltip>
</span>
</span>

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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()

View File

@@ -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: {

View File

@@ -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

View File

@@ -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) => {

View File

@@ -573,7 +573,11 @@ export class ProjectBase<TServer extends ServerE2E | ServerCt> 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,

View File

@@ -327,6 +327,10 @@ export abstract class ServerBase<TSocket extends SocketE2E | SocketCt> {
this.networkProxy.addPendingBrowserPreRequest(browserPreRequest)
}
emitRequestEvent (eventName, data) {
this.socket.toDriver('request:event', eventName, data)
}
_createHttpServer (app): DestroyableHttpServer {
const svr = http.createServer(httpUtils.lenientOptions, app)

View File

@@ -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,