Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use websockets to stub large XHR response bodies instead of headers #5525

Merged
merged 15 commits into from
Oct 30, 2019
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/driver/src/cy/commands/xhr.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ startXhrServer = (cy, state, config) ->
xhrUrl: config("xhrUrl")
stripOrigin: stripOrigin

emitIncoming: (id, route) ->
Cypress.backend('incoming:xhr', id, route)

## shouldnt these stubs be called routes?
## rename everything related to stubs => routes
onSend: (xhr, stack, route) =>
Expand Down Expand Up @@ -256,7 +259,7 @@ module.exports = (Commands, Cypress, cy, state, config) ->
## window such as if the last test ended
## with a cross origin window
try
server = startXhrServer(cy, state, config)
server = startXhrServer(cy, state, config, Cypress)
catch err
## in this case, just don't bind to the server
server = null
Expand Down
41 changes: 25 additions & 16 deletions packages/driver/src/cypress/server.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,8 @@ props = "onreadystatechange onload onerror".split(" ")

restoreFn = null

setHeader = (xhr, key, val, transformer) ->
setHeader = (xhr, key, val) ->
if val?
if transformer
val = transformer(val)

key = "X-Cypress-" + _.capitalize(key)
xhr.setRequestHeader(key, encodeURI(val))

Expand Down Expand Up @@ -176,18 +173,30 @@ create = (options = {}) ->
hasEnabledStubs and route and route.response?

applyStubProperties: (xhr, route) ->
responser = if _.isObject(route.response) then JSON.stringify else null

## add header properties for the xhr's id
## and the testId
setHeader(xhr, "id", xhr.id)
# setHeader(xhr, "testId", options.testId)

setHeader(xhr, "status", route.status)
setHeader(xhr, "response", route.response, responser)
setHeader(xhr, "matched", route.url + "")
setHeader(xhr, "delay", route.delay)
setHeader(xhr, "headers", route.headers, transformHeaders)
responseToString = =>
if not _.isString(route.response)
return JSON.stringify(route.response)

route.response

response = responseToString()

headers = {
"id": xhr.id
"status": route.status
"matched": route.url + ""
"delay": route.delay
"headers": transformHeaders(route.headers)
}

if response.length > 4096
options.emitIncoming(xhr.id, response)
headers.responseDeferred = true
else
headers.response = response

_.map headers, (v, k) =>
setHeader(xhr, k, v)

route: (attrs = {}) ->
warnOnStubDeprecation(attrs, "route")
Expand Down
4 changes: 4 additions & 0 deletions packages/runner/src/lib/event-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,10 @@ const eventManager = {
})
})

Cypress.on('test:before:run:async', () => {
Cypress.backend('reset:xhr:server')
})
flotwig marked this conversation as resolved.
Show resolved Hide resolved

Cypress.on('script:error', (err) => {
Cypress.stop()
localBus.emit('script:error', err)
Expand Down
11 changes: 6 additions & 5 deletions packages/server/__snapshots__/4_xhr_spec.coffee.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,21 @@ exports['e2e xhr / passes'] = `
✓ does not inject into json's contents from http server even requesting text/html
✓ does not inject into json's contents from file server even requesting text/html
✓ works prior to visit
✓ can stub a 100kb response
server with 1 visit
✓ response body
✓ request body
✓ aborts


8 passing
9 passing


(Results)

┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Tests: 8
│ Passing: 8
│ Tests: 9
│ Passing: 9
│ Failing: 0 │
│ Pending: 0 │
│ Skipped: 0 │
Expand All @@ -60,9 +61,9 @@ exports['e2e xhr / passes'] = `

Spec Tests Passing Failing Pending Skipped
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ✔ xhr_spec.coffee XX:XX 8 8 - - - │
│ ✔ xhr_spec.coffee XX:XX 9 9 - - - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
✔ All specs passed! XX:XX 8 8 - - -
✔ All specs passed! XX:XX 9 9 - - -


`
15 changes: 11 additions & 4 deletions packages/server/lib/controllers/xhrs.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ isValidJSON = (text) ->
return false

module.exports = {
handle: (req, res, config, next) ->
handle: (req, res, getDeferredResponse, config, next) ->
get = (val, def) ->
decodeURI(req.get(val) ? def)

Expand All @@ -27,6 +27,10 @@ module.exports = {
headers = get("x-cypress-headers", null)
response = get("x-cypress-response", "")

if get("x-cypress-responsedeferred", "")
id = get("x-cypress-id")
response = getDeferredResponse(id)

respond = =>
## figure out the stream interface and pipe these
## chunks to the response
Expand Down Expand Up @@ -79,10 +83,13 @@ module.exports = {
{data: bytes, encoding: encoding}

getResponse: (resp, config) ->
if resp.then
return resp
flotwig marked this conversation as resolved.
Show resolved Hide resolved

if fixturesRe.test(resp)
@_get(resp, config)
else
Promise.resolve({data: resp})
return @_get(resp, config)

Promise.resolve({data: resp})

parseContentType: (response) ->
ret = (type) ->
Expand Down
4 changes: 2 additions & 2 deletions packages/server/lib/routes.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ files = require("./controllers/files")
proxy = require("./controllers/proxy")
staticCtrl = require("./controllers/static")

module.exports = (app, config, request, getRemoteState, project, nodeProxy) ->
module.exports = (app, config, request, getRemoteState, getDeferredResponse, project, nodeProxy) ->
## routing for the actual specs which are processed automatically
## this could be just a regular .js file or a .coffee file
app.get "/__cypress/tests", (req, res, next) ->
Expand Down Expand Up @@ -45,7 +45,7 @@ module.exports = (app, config, request, getRemoteState, project, nodeProxy) ->
files.handleIframe(req, res, config, getRemoteState)

app.all "/__cypress/xhrs/*", (req, res, next) ->
xhrs.handle(req, res, config, next)
xhrs.handle(req, res, getDeferredResponse, config, next)

app.get "/__root/*", (req, res, next) ->
file = path.join(config.projectRoot, req.params[0])
Expand Down
7 changes: 5 additions & 2 deletions packages/server/lib/server.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ logger = require("./logger")
Socket = require("./socket")
Request = require("./request")
fileServer = require("./file_server")
XhrServer = require("./xhr_ws_server")

DEFAULT_DOMAIN_NAME = "localhost"
fullyQualifiedRe = /^https?:\/\//
Expand Down Expand Up @@ -145,12 +146,13 @@ class Server
## and set the responseTimeout
@_request = Request({timeout: config.responseTimeout})
@_nodeProxy = httpProxy.createProxyServer()
@_xhrServer = XhrServer.create()

getRemoteState = => @_getRemoteState()

@createHosts(config.hosts)

@createRoutes(app, config, @_request, getRemoteState, project, @_nodeProxy)
@createRoutes(app, config, @_request, getRemoteState, @_xhrServer.getDeferredResponse, project, @_nodeProxy)

@createServer(app, config, project, @_request, onWarning)

Expand Down Expand Up @@ -702,10 +704,11 @@ class Server
startWebsockets: (automation, config, options = {}) ->
options.onResolveUrl = @_onResolveUrl.bind(@)
options.onRequest = @_onRequest.bind(@)
options.onIncomingXhr = @_xhrServer.onIncomingXhr
options.onResetXhrServer = @_xhrServer.reset

@_socket = Socket(config)
@_socket.startListening(@_server, automation, config, options)
@_normalizeReqUrl(@_server)
# handleListeners(@_server)

module.exports = Server
9 changes: 9 additions & 0 deletions packages/server/lib/socket.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ class Socket

_.defaults options,
socketId: null
onBeforeTestRun: ->
flotwig marked this conversation as resolved.
Show resolved Hide resolved
onIncomingXhr: ->
onSetRunnables: ->
onMocha: ->
onConnect: ->
Expand Down Expand Up @@ -298,6 +300,10 @@ class Socket
options.onResolveUrl(url, headers, automationRequest, resolveOpts)
when "http:request"
options.onRequest(headers, automationRequest, args[0])
when "reset:xhr:server"
options.onResetXhrServer()
when "incoming:xhr"
options.onIncomingXhr(args[0], args[1])
when "get:fixture"
fixture.get(config.fixturesFolder, args[0], args[1])
when "read:file"
Expand Down Expand Up @@ -335,6 +341,8 @@ class Socket
socket.on "external:open", (url) ->
require("electron").shell.openExternal(url)

socket.on "test:before:run:async", options.onBeforeTestRun
flotwig marked this conversation as resolved.
Show resolved Hide resolved

reporterEvents.forEach (event) =>
socket.on event, (data) =>
@toRunner(event, data)
Expand All @@ -343,6 +351,7 @@ class Socket
socket.on event, (data) =>
@toReporter(event, data)


end: ->
@ended = true

Expand Down
70 changes: 70 additions & 0 deletions packages/server/lib/xhr_ws_server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import _ from 'lodash'
import Bluebird from 'bluebird'
import debugModule from 'debug'

const debug = debugModule('cypress:server:xhr_ws_server')

function trunc (str) {
return _.truncate(str, {
length: 100,
omission: '... [truncated to 100 chars]',
})
}

export function create () {
let incomingXhrs: any = {}

function onIncomingXhr (id, data) {
debug('onIncomingXhr %o', { id, res: trunc(data) })
const deferred = incomingXhrs[id]

if (deferred) {
return deferred.resolve({
data,
})
}

incomingXhrs[id] = data
}

function getDeferredResponse (id) {
debug('getDeferredResponse %o', { id })
// if we already have it, send it
const res = incomingXhrs[id]

if (res) {
if (res.then) {
debug('returning existing deferred promise for %o', { id })
flotwig marked this conversation as resolved.
Show resolved Hide resolved
}

debug('already have deferred response %o', { id, res: trunc(res) })
delete incomingXhrs[id]

return res
}

return new Bluebird((resolve, reject) => {
debug('do not have response, waiting %o', { id })
incomingXhrs[id] = { resolve, reject }
})
.tap((res) => {
debug('deferred response found %o', { id, res: trunc(res) })
})
}

function reset () {
debug('resetting incomingXhrs %o', { length: incomingXhrs.length })

_.forEach(incomingXhrs, ({ reject }) => {
reject(new Error('This stubbed XHR was pending on a stub response object from the driver, but the test ended before that happened.'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this does not feel correct - tests can end while the XHR is in flight without that being an actual error

Copy link
Contributor Author

@flotwig flotwig Oct 30, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really just to ensure that the HTTP request is closed before creating new tests; otherwise, the connections would pile up until they timed out.

It could also be fulfilled with a successful response, or left to time out. Ed: Or we could try killing the socket.

})

incomingXhrs = {}
}

return {
onIncomingXhr,
getDeferredResponse,
reset,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,33 @@ describe "xhrs", ->
it "works prior to visit", ->
cy.server()

## https://github.com/cypress-io/cypress/issues/5431
it "can stub a 100kb response", (done) ->
body = 'X'.repeat(100 * 1024)

cy.server()
cy.route({
method: 'POST'
url: '/foo'
response: {
'bar': body
}
})

cy.visit("/index.html")
.then (win) ->
xhr = new win.XMLHttpRequest
xhr.open("POST", "/foo")
xhr.send()

finish = ->
expect(xhr.status).to.eq(200)
expect(xhr.responseText).to.include(body)
done()

xhr.onload = finish
xhr.onerror = finish

describe "server with 1 visit", ->
before ->
cy.visit("/xhr.html")
Expand Down
36 changes: 36 additions & 0 deletions packages/server/test/unit/xhr_ws_server_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import chai, { expect } from 'chai'
import chaiAsPromised from 'chai-as-promised'
import * as XhrServer from '../../lib/xhr_ws_server'

chai.use(chaiAsPromised)

describe('lib/xhr_ws_server', function () {
context('#create', function () {
let xhrServer

beforeEach(function () {
xhrServer = XhrServer.create()
})

it('resolves a response when incomingXhr is received before request', function () {
xhrServer.onIncomingXhr('foo', 'bar')
expect(xhrServer.getDeferredResponse('foo')).to.eq('bar')
})

it('resolves a response when incomingXhr is received after request', function () {
const p = xhrServer.getDeferredResponse('foo')

xhrServer.onIncomingXhr('foo', 'bar')

return expect(p).to.eventually.deep.eq({ data: 'bar' })
})

it('rejects a response when incomingXhr is received and test gets reset', function () {
const p = xhrServer.getDeferredResponse('foo')

xhrServer.reset()

return expect(p).to.be.rejectedWith('This stubbed XHR was pending')
})
})
})