Files
cypress/packages/server/lib/server.coffee
Chris Breiding 6ba8d7cc93 Electron v5.0.10 (#4720)
* fix specs

* use debugger protocol for cookie handling in electron

* use latest gulp

* use rimraf instead of gulp-clean

* use electron 3.1.8 and node 10.2.1

* use gulp 4 in packages/static

* fix sendCommandAsync, log Schema.getDomains on CDP connect

* autofill e2e test name [skip ci]

* electron@5.0.7, see what new failures exist

* --no-sandbox for launching Electron

* update cookies logic for electron

* node 12

* update snapshot for new node

* update error message for new node

* stub sendCommandAsync

* only connect to socket if path has been replaced, fixes #4776

* update node-sass to support node 12

* skip wacky socket tests for now

* snapshot

* fix run_plugins_spec snapshot, don't include stack trace

* use --no-sandbox on linux to run as root

* allow sendCommandAsync to resolve

* use euid for root check

* log domains even if undefined

* don't worry about ending 1xx responses immediately anymore

* use --max-http-header-size, change max size from 8kb to 1mb, fix #76

* do not send 502 on failed websocket, just send back ECONNRESET

* update websocket spec port to not collide with other test

* update outdated expect

* Revert "only connect to socket if path has been replaced, fixes #4776"

This reverts commit f179eda5ca.

* update gulp in root

* update https-proxy unit tests

* update network spec to properly close server

* update reporter spec

* update https-proxy-agent to fix node 10.10.0 change

discussion: https://github.com/nodejs/node/issues/24474\#issuecomment-511963799

* only pass --max-http-header-size on node >=12

* use own server-destroy implementation that supports secureConnect events

* oops

* update socket_spec

* electron 6.0.0

* console.table introduced in node 10

* change browserify entry to init.js

* handle edge case when no response body

* console.table added in node 10

* do not exit app when all BrowserWindows are closed

* update e2e snapshots

* value may not be null

* update plugins spec

* correct cookie expiry, use browser.getversion for CDP version check

* fix snapshotting for require stacks

* reorder cookies in spec

* warn when depreated electron callback apis are used

* only report 1 plugin error per process

* cleanup

* node 12.4.0, cypress/browsers:node12.4.0-chrome76 docker image

* update shell.openExternal to promisified

* update dialog.showOpenDialog to promisified

* update webContents.session.setProxy to promisified

* updating native dependencies since we don't need ancient node ABI support anymore

* WIP: switch cookies to simpler, jar-less approach

* WIP: switch cookies to simpler, jar-less approach

* making tests pass

* improve cookie filtering logic

* Remove unneeded Promise.try

* filter what makes it to the extension

* properly re-set superdomain cookies on cross-origin cy.visit

* allow comma-separated list of e2e tests

* sort cookies in order of expiration date, ascending

* updating tests, cleanup

* update tests

* version electron as a devDependency, electron@6.0.1

* cleanup, remove old automation

* cleanup, remove old automation

* bump chokidar to fix win10 + node12 issue

was seeing this on windows:
https://github.com/nuxt/nuxt.js/issues/6035

fixed with version bump

* enable now-supported quit role, re-enable old tests

* don't need that arg there

* remove last deprecated callback electron invocations

* Delete cypress.json

* responding to PR feedback

* cleanup

* invoke

* use 'quit' role

* Use new appMenu role for Cypress menu on mac

* electron@6.0.2

* electron@6.0.3

* remove domain: cookie.domain and see what happens

* remove setErrorHandler

* Revert "remove domain: cookie.domain and see what happens"

This reverts commit 49e916896d.

* add unit tests for cookies

* ci

* fix project-content css

* electron@6.0.4

* fix specs_list test

* electron@6.0.7

* some cleanup

* electron@6.0.9

* Update 8_reporters_spec.coffee.js

* electron@5.0.10 - Chromium 73, Node 12

* cli: fix the STDIN pipe on Windows (#5045)

* cli: pipe stdin

* uggh, here is the actual change

* update cli unit tests

* add unit test

* more permissive check for json to include application/vnd.api+j… (#5166)

* more permissive check for json to include

* add json test for content-type application/vnd.api+json

* cruder solution passes e2e tests locally, so let's go with that

* Remove 'charset' from content-type before checking if JSON

* fix eslint for fixture specs (#5176)

* update eslint to lint files within 'fixtures' in support files

- ignore some edge cases like jquery, jsx and obvious js files we wrote
with broken code

* Fixes from eslint to 'fixtures' files

* Catch env variable with reserved name CYPRESS_ENV 1621 (#1626)

* server: check CYPRESS_ENV variable when merging configs

* catch invalid CYPRESS_ENV value in CLI, close #1621

* linting

* sanitize platform in test snapshot

* linting

* update error message text

* add missing comma

* fix finally merge in JS code

* pass CLI linter

* fix log reference, should be debug

* use correct sinon reference

* update message, show first part in red

* update error message text

* Addresses #2953 (#5174)

* Addresses #2953

* Added proper test for new error message

* Didn't realize it ran this test as well, whoops

* Implementing changes as suggested by @jennifer-shehane

* Fixing tests and error output. Moved the checks to the start of the get command to ensure we always catch improper options

* Removing issue test since the querying spec covers it

* Using coffescript isArray check

* depromisify things that were promisified b/t electron 5 <=> 6

Revert "update shell.openExternal to promisified"

This reverts commit 8b6460d015.

Revert "update dialog.showOpenDialog to promisified"

This reverts commit 5f178b075b.

Revert "update webContents.session.setProxy to promisified"

This reverts commit 727df3a4e5.

* node12.4.0-chrome76 => node12.0.0-chrome75

* fix tests for electron downgrade

* node12.0.0-chrome75 => node12.0.0-chrome73


Co-authored-by: Zach Bloomquist <github@chary.us>
Co-authored-by: Brian Mann <brian.mann86@gmail.com>
2019-09-24 14:35:24 -04:00

712 lines
21 KiB
CoffeeScript

_ = require("lodash")
exphbs = require("express-handlebars")
url = require("url")
http = require("http")
concatStream = require("concat-stream")
stream = require("stream")
express = require("express")
Promise = require("bluebird")
evilDns = require("evil-dns")
isHtml = require("is-html")
httpProxy = require("http-proxy")
la = require("lazy-ass")
check = require("check-more-types")
httpsProxy = require("@packages/https-proxy")
compression = require("compression")
debug = require("debug")("cypress:server:server")
agent = require("@packages/network").agent
cors = require("./util/cors")
uri = require("./util/uri")
origin = require("./util/origin")
ensureUrl = require("./util/ensure-url")
appData = require("./util/app_data")
buffers = require("./util/buffers")
blacklist = require("./util/blacklist")
statusCode = require("./util/status_code")
headersUtil = require("./util/headers")
allowDestroy = require("./util/server_destroy")
cwd = require("./cwd")
errors = require("./errors")
logger = require("./logger")
Socket = require("./socket")
Request = require("./request")
fileServer = require("./file_server")
DEFAULT_DOMAIN_NAME = "localhost"
fullyQualifiedRe = /^https?:\/\//
isResponseHtml = (contentType, responseBuffer) ->
if contentType
return contentType is "text/html"
if body = _.invoke(responseBuffer, 'toString')
return isHtml(body)
return false
setProxiedUrl = (req) ->
## bail if we've already proxied the url
return if req.proxiedUrl
## backup the original proxied url
## and slice out the host/origin
## and only leave the path which is
## how browsers would normally send
## use their url
req.proxiedUrl = uri.removeDefaultPort(req.url).format()
req.url = uri.getPath(req.url)
notSSE = (req, res) ->
req.headers.accept isnt "text/event-stream" and compression.filter(req, res)
## currently not making use of event emitter
## but may do so soon
class Server
constructor: ->
if not (@ instanceof Server)
return new Server()
@_request = null
@_middleware = null
@_server = null
@_socket = null
@_baseUrl = null
@_nodeProxy = null
@_fileServer = null
@_httpsProxy = null
@_urlResolver = null
createExpressApp: (morgan) ->
app = express()
## set the cypress config from the cypress.json file
app.set("view engine", "html")
## since we use absolute paths, configure express-handlebars to not automatically find layouts
## https://github.com/cypress-io/cypress/issues/2891
app.engine("html", exphbs({
defaultLayout: false
layoutsDir: []
partialsDir: []
}))
## handle the proxied url in case
## we have not yet started our websocket server
app.use (req, res, next) =>
setProxiedUrl(req)
## if we've defined some middlware
## then call this. useful in tests
if m = @_middleware
m(req, res)
## always continue on
next()
app.use require("cookie-parser")()
app.use compression({filter: notSSE})
app.use require("morgan")("dev") if morgan
## errorhandler
app.use require("errorhandler")()
## remove the express powered-by header
app.disable("x-powered-by")
return app
createRoutes: ->
require("./routes").apply(null, arguments)
getHttpServer: -> @_server
portInUseErr: (port) ->
e = errors.get("PORT_IN_USE_SHORT", port)
e.port = port
e.portInUse = true
e
open: (config = {}, project, onWarning) ->
la(_.isPlainObject(config), "expected plain config object", config)
Promise.try =>
## always reset any buffers
## TODO: change buffers to be an instance
## here and pass this dependency around
buffers.reset()
app = @createExpressApp(config.morgan)
logger.setSettings(config)
## generate our request instance
## and set the responseTimeout
@_request = Request({timeout: config.responseTimeout})
@_nodeProxy = httpProxy.createProxyServer()
getRemoteState = => @_getRemoteState()
@createHosts(config.hosts)
@createRoutes(app, config, @_request, getRemoteState, project, @_nodeProxy)
@createServer(app, config, project, @_request, onWarning)
createHosts: (hosts = {}) ->
_.each hosts, (ip, host) ->
evilDns.add(host, ip)
createServer: (app, config, project, request, onWarning) ->
new Promise (resolve, reject) =>
{port, fileServerFolder, socketIoRoute, baseUrl, blacklistHosts} = config
@_server = http.createServer(app)
allowDestroy(@_server)
onError = (err) =>
## if the server bombs before starting
## and the err no is EADDRINUSE
## then we know to display the custom err message
if err.code is "EADDRINUSE"
reject @portInUseErr(port)
onUpgrade = (req, socket, head) =>
debug("Got UPGRADE request from %s", req.url)
@proxyWebsockets(@_nodeProxy, socketIoRoute, req, socket, head)
callListeners = (req, res) =>
listeners = @_server.listeners("request").slice(0)
@_callRequestListeners(@_server, listeners, req, res)
onSniUpgrade = (req, socket, head) =>
upgrades = @_server.listeners("upgrade").slice(0)
for upgrade in upgrades
upgrade.call(@_server, req, socket, head)
@_server.on "connect", (req, socket, head) =>
debug("Got CONNECT request from %s", req.url)
@_httpsProxy.connect(req, socket, head, {
onDirectConnection: (req) =>
urlToCheck = "https://" + req.url
isMatching = cors.urlMatchesOriginPolicyProps(urlToCheck, @_remoteProps)
word = if isMatching then "does" else "does not"
debug("HTTPS request #{word} match URL: #{urlToCheck} with props: %o", @_remoteProps)
## if we are currently matching then we're
## not making a direct connection anyway
## so we only need to check this if we
## have blacklist hosts and are not matching.
##
## if we have blacklisted hosts lets
## see if this matches - if so then
## we cannot allow it to make a direct
## connection
if blacklistHosts and not isMatching
isMatching = blacklist.matches(urlToCheck, blacklistHosts)
debug("HTTPS request #{urlToCheck} matches blacklist?", isMatching)
## make a direct connection only if
## our req url does not match the origin policy
## which is the superDomain + port
return not isMatching
})
@_server.on "upgrade", onUpgrade
@_server.once "error", onError
@_listen(port, onError)
.then (port) =>
Promise.all([
httpsProxy.create(appData.path("proxy"), port, {
onRequest: callListeners
onUpgrade: onSniUpgrade
}),
fileServer.create(fileServerFolder)
])
.spread (httpsProxy, fileServer) =>
@_httpsProxy = httpsProxy
@_fileServer = fileServer
## if we have a baseUrl let's go ahead
## and make sure the server is connectable!
if baseUrl
@_baseUrl = baseUrl
if config.isTextTerminal
return @_retryBaseUrlCheck(baseUrl, onWarning)
.return(null)
.catch (e) ->
debug(e)
reject(errors.get("CANNOT_CONNECT_BASE_URL", baseUrl))
ensureUrl.isListening(baseUrl)
.return(null)
.catch (err) ->
errors.get("CANNOT_CONNECT_BASE_URL_WARNING", baseUrl)
.then (warning) =>
## once we open set the domain
## to root by default
## which prevents a situation where navigating
## to http sites redirects to /__/ cypress
@_onDomainSet(baseUrl ? "<root>")
resolve([port, warning])
_port: ->
_.chain(@_server).invoke("address").get("port").value()
_listen: (port, onError) ->
new Promise (resolve) =>
listener = =>
address = @_server.address()
@isListening = true
debug("Server listening on ", address)
@_server.removeListener "error", onError
resolve(address.port)
@_server.listen(port || 0, '127.0.0.1', listener)
_getRemoteState: ->
# {
# origin: "http://localhost:2020"
# fileServer:
# strategy: "file"
# domainName: "localhost"
# props: null
# }
# {
# origin: "https://foo.google.com"
# strategy: "http"
# domainName: "google.com"
# props: {
# port: 443
# tld: "com"
# domain: "google"
# }
# }
props = _.extend({}, {
auth: @_remoteAuth
props: @_remoteProps
origin: @_remoteOrigin
strategy: @_remoteStrategy
visiting: @_remoteVisitingUrl
domainName: @_remoteDomainName
fileServer: @_remoteFileServer
})
debug("Getting remote state: %o", props)
return props
_onRequest: (headers, automationRequest, options) ->
@_request.sendPromise(headers, automationRequest, options)
_onResolveUrl: (urlStr, headers, automationRequest, options = {}) ->
debug("resolving visit %o", {
url: urlStr
headers
options
})
startTime = new Date()
## if we have an existing url resolver
## in flight then cancel it
if @_urlResolver
@_urlResolver.cancel()
request = @_request
handlingLocalFile = false
previousState = _.clone @_getRemoteState()
## nuke any hashes from our url since
## those those are client only and do
## not apply to http requests
urlStr = url.parse(urlStr)
urlStr.hash = null
urlStr = urlStr.format()
originalUrl = urlStr
reqStream = null
currentPromisePhase = null
runPhase = (fn) ->
return currentPromisePhase = fn()
return @_urlResolver = p = new Promise (resolve, reject, onCancel) =>
onCancel ->
p.currentPromisePhase = currentPromisePhase
p.reqStream = reqStream
_.invoke(reqStream, "abort")
_.invoke(currentPromisePhase, "cancel")
## if we have a buffer for this url
## then just respond with its details
## so we are idempotant and do not make
## another request
if obj = buffers.getByOriginalUrl(urlStr)
debug("got previous request buffer for url:", urlStr)
## reset the cookies from the buffer on the browser
return runPhase ->
resolve(
Promise.map obj.details.cookies, _.partial(automationRequest, 'set:cookie')
.return(obj.details)
)
redirects = []
newUrl = null
if not fullyQualifiedRe.test(urlStr)
handlingLocalFile = true
@_remoteVisitingUrl = true
@_onDomainSet(urlStr, options)
## TODO: instead of joining remoteOrigin here
## we can simply join our fileServer origin
## and bypass all the remoteState.visiting shit
urlFile = url.resolve(@_remoteFileServer, urlStr)
urlStr = url.resolve(@_remoteOrigin, urlStr)
onReqError = (err) =>
## only restore the previous state
## if our promise is still pending
if p.isPending()
restorePreviousState()
reject(err)
onReqStreamReady = (str) =>
reqStream = str
str
.on("error", onReqError)
.on "response", (incomingRes) =>
debug(
"resolve:url headers received, buffering response %o",
_.pick(incomingRes, "headers", "statusCode")
)
newUrl ?= urlStr
runPhase =>
## get the cookies that would be sent with this request so they can be rehydrated
automationRequest("get:cookies", {
domain: cors.getSuperDomain(newUrl)
})
.then (cookies) =>
@_remoteVisitingUrl = false
statusIs2xxOrAllowedFailure = ->
## is our status code in the 2xx range, or have we disabled failing
## on status code?
statusCode.isOk(incomingRes.statusCode) or (options.failOnStatusCode is false)
isOk = statusIs2xxOrAllowedFailure()
contentType = headersUtil.getContentType(incomingRes)
details = {
isOkStatusCode: isOk
contentType
url: newUrl
status: incomingRes.statusCode
cookies
statusText: statusCode.getText(incomingRes.statusCode)
redirects
originalUrl
}
## does this response have this cypress header?
if fp = incomingRes.headers["x-cypress-file-path"]
## if so we know this is a local file request
details.filePath = fp
debug("setting details resolving url %o", details)
concatStr = concatStream (responseBuffer) =>
## buffer the entire response before resolving.
## this allows us to detect & reject ETIMEDOUT errors
## where the headers have been sent but the
## connection hangs before receiving a body.
if !_.get(responseBuffer, 'length')
## concatStream can yield an empty array, which is
## not a valid chunk
responseBuffer = undefined
## if there is not a content-type, try to determine
## if the response content is HTML-like
## https://github.com/cypress-io/cypress/issues/1727
details.isHtml = isResponseHtml(contentType, responseBuffer)
debug("resolve:url response ended, setting buffer %o", { newUrl, details })
details.totalTime = new Date() - startTime
## TODO: think about moving this logic back into the
## frontend so that the driver can be in control of
## when the server should cache the request buffer
## and set the domain vs not
if isOk and details.isHtml
## reset the domain to the new url if we're not
## handling a local file
@_onDomainSet(newUrl, options) if not handlingLocalFile
responseBufferStream = new stream.PassThrough({
highWaterMark: Number.MAX_SAFE_INTEGER
})
responseBufferStream.end(responseBuffer)
buffers.set({
url: newUrl
stream: responseBufferStream
details
originalUrl: originalUrl
response: incomingRes
})
else
## TODO: move this logic to the driver too for
## the same reasons listed above
restorePreviousState()
resolve(details)
str.pipe(concatStr)
.catch(onReqError)
restorePreviousState = =>
@_remoteAuth = previousState.auth
@_remoteProps = previousState.props
@_remoteOrigin = previousState.origin
@_remoteStrategy = previousState.strategy
@_remoteFileServer = previousState.fileServer
@_remoteDomainName = previousState.domainName
@_remoteVisitingUrl = previousState.visiting
# if they're POSTing an object, querystringify their POST body
if options.method == 'POST' and _.isObject(options.body)
options.form = options.body
delete options.body
_.assign(options, {
## turn off gzip since we need to eventually
## rewrite these contents
gzip: false
url: urlFile ? urlStr
headers: _.assign({
accept: "text/html,*/*"
}, options.headers)
onBeforeReqInit: runPhase
followRedirect: (incomingRes) ->
status = incomingRes.statusCode
next = incomingRes.headers.location
curr = newUrl ? urlStr
newUrl = url.resolve(curr, next)
redirects.push([status, newUrl].join(": "))
return true
})
debug('sending request with options %o', options)
runPhase ->
request.sendStream(headers, automationRequest, options)
.then (createReqStream) ->
onReqStreamReady(createReqStream())
.catch(onReqError)
_onDomainSet: (fullyQualifiedUrl, options = {}) ->
l = (type, val) ->
debug("Setting", type, val)
@_remoteAuth = options.auth
l("remoteAuth", @_remoteAuth)
## if this isn't a fully qualified url
## or if this came to us as <root> in our tests
## then we know to go back to our default domain
## which is the localhost server
if fullyQualifiedUrl is "<root>" or not fullyQualifiedRe.test(fullyQualifiedUrl)
@_remoteOrigin = "http://#{DEFAULT_DOMAIN_NAME}:#{@_port()}"
@_remoteStrategy = "file"
@_remoteFileServer = "http://#{DEFAULT_DOMAIN_NAME}:#{@_fileServer?.port()}"
@_remoteDomainName = DEFAULT_DOMAIN_NAME
@_remoteProps = null
l("remoteOrigin", @_remoteOrigin)
l("remoteStrategy", @_remoteStrategy)
l("remoteHostAndPort", @_remoteProps)
l("remoteDocDomain", @_remoteDomainName)
l("remoteFileServer", @_remoteFileServer)
else
@_remoteOrigin = origin(fullyQualifiedUrl)
@_remoteStrategy = "http"
@_remoteFileServer = null
## set an object with port, tld, and domain properties
## as the remoteHostAndPort
@_remoteProps = cors.parseUrlIntoDomainTldPort(@_remoteOrigin)
@_remoteDomainName = _.compact([@_remoteProps.domain, @_remoteProps.tld]).join(".")
l("remoteOrigin", @_remoteOrigin)
l("remoteHostAndPort", @_remoteProps)
l("remoteDocDomain", @_remoteDomainName)
return @_getRemoteState()
_callRequestListeners: (server, listeners, req, res) ->
for listener in listeners
listener.call(server, req, res)
_normalizeReqUrl: (server) ->
## because socket.io removes all of our request
## events, it forces the socket.io traffic to be
## handled first.
## however we need to basically do the same thing
## it does and after we call into socket.io go
## through and remove all request listeners
## and change the req.url by slicing out the host
## because the browser is in proxy mode
listeners = server.listeners("request").slice(0)
server.removeAllListeners("request")
server.on "request", (req, res) =>
setProxiedUrl(req)
@_callRequestListeners(server, listeners, req, res)
_retryBaseUrlCheck: (baseUrl, onWarning) ->
ensureUrl.retryIsListening(baseUrl, {
retryIntervals: [3000, 3000, 4000],
onRetry: ({ attempt, delay, remaining }) ->
warning = errors.get("CANNOT_CONNECT_BASE_URL_RETRYING", {
remaining
attempt
delay
baseUrl
})
onWarning(warning)
})
proxyWebsockets: (proxy, socketIoRoute, req, socket, head) ->
## bail if this is our own namespaced socket.io request
return if req.url.startsWith(socketIoRoute)
if (host = req.headers.host) and @_remoteProps and (remoteOrigin = @_remoteOrigin)
## get the port from @_remoteProps
## get the protocol from remoteOrigin
## get the hostname from host header
{port} = @_remoteProps
{protocol} = url.parse(remoteOrigin)
{hostname} = url.parse("http://#{host}")
onProxyErr = (err, req, res) ->
debug("Got ERROR proxying websocket connection", { err, port, protocol, hostname, req })
proxy.ws(req, socket, head, {
secure: false
target: {
host: hostname
port: port
protocol: protocol
}
agent: agent
}, onProxyErr)
else
## we can't do anything with this socket
## since we don't know how to proxy it!
socket.end() if socket.writable
reset: ->
buffers.reset()
@_onDomainSet(@_baseUrl ? "<root>")
_close: ->
@reset()
logger.unsetSettings()
evilDns.clear()
## bail early we dont have a server or we're not
## currently listening
return Promise.resolve() if not @_server or not @isListening
@_server.destroyAsync()
.then =>
@isListening = false
close: ->
Promise.join(
@_close()
@_socket?.close()
@_fileServer?.close()
@_httpsProxy?.close()
)
.then =>
## reset any middleware
@_middleware = null
end: ->
@_socket and @_socket.end()
changeToUrl: (url) ->
@_socket and @_socket.changeToUrl(url)
onTestFileChange: (filePath) ->
@_socket and @_socket.onTestFileChange(filePath)
onRequest: (fn) ->
@_middleware = fn
onNextRequest: (fn) ->
@onRequest =>
fn.apply(@, arguments)
@_middleware = null
startWebsockets: (automation, config, options = {}) ->
options.onResolveUrl = @_onResolveUrl.bind(@)
options.onRequest = @_onRequest.bind(@)
@_socket = Socket(config)
@_socket.startListening(@_server, automation, config, options)
@_normalizeReqUrl(@_server)
# handleListeners(@_server)
module.exports = Server