diff --git a/cli/types/index.d.ts b/cli/types/index.d.ts index c5fe14ccaf95..7a7a2220cfa6 100644 --- a/cli/types/index.d.ts +++ b/cli/types/index.d.ts @@ -1588,9 +1588,14 @@ declare namespace Cypress { * @example * cy.visit('http://localhost:3000') * cy.visit('/somewhere') // opens ${baseUrl}/somewhere + * cy.visit({ + * url: 'http://google.com', + * method: 'POST' + * }) * */ visit(url: string, options?: Partial): Chainable + visit(options: Partial & { url: string }): Chainable /** * Wait for a number of milliseconds. diff --git a/packages/driver/src/cy/commands/navigation.coffee b/packages/driver/src/cy/commands/navigation.coffee index 4ca568774140..c21b4006a9fe 100644 --- a/packages/driver/src/cy/commands/navigation.coffee +++ b/packages/driver/src/cy/commands/navigation.coffee @@ -28,6 +28,11 @@ reset = (test = {}) -> id = test.id +VALID_VISIT_METHODS = ['GET', 'POST'] + +isValidVisitMethod = (method) -> + _.includes(VALID_VISIT_METHODS, method) + timedOutWaitingForPageLoad = (ms, log) -> $utils.throwErrByPath("navigation.timed_out", { onFail: log @@ -260,7 +265,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> Cypress.backend( "resolve:url", url, - _.pick(options, "failOnStatusCode", "auth") + _.pick(options, "auth", "failOnStatusCode", "method", "body", "headers") ) .then (resp = {}) -> switch @@ -456,22 +461,45 @@ module.exports = (Commands, Cypress, cy, state, config) -> $utils.throwErrByPath("go.invalid_argument", { onFail: options._log }) visit: (url, options = {}) -> + if options.url and url + $utils.throwErrByPath("visit.no_duplicate_url", { args: { optionsUrl: options.url, url: url }}) + + if _.isObject(url) and _.isEqual(options, {}) + ## options specified as only argument + options = url + url = options.url + if not _.isString(url) $utils.throwErrByPath("visit.invalid_1st_arg") _.defaults(options, { auth: null failOnStatusCode: true + method: 'GET' + body: null + headers: {} log: true timeout: config("pageLoadTimeout") onBeforeLoad: -> onLoad: -> }) + if not isValidVisitMethod(options.method) + $utils.throwErrByPath("visit.invalid_method", { args: { method: options.method }}) + + if not _.isObject(options.headers) + $utils.throwErrByPath("visit.invalid_headers") + consoleProps = {} if options.log + message = url + + if options.method != 'GET' + message = "#{options.method} #{message}" + options._log = Cypress.log({ + message: message consoleProps: -> consoleProps }) @@ -598,11 +626,13 @@ module.exports = (Commands, Cypress, cy, state, config) -> if url isnt originalUrl consoleProps["Original Url"] = originalUrl - if options.log and redirects and redirects.length - indicateRedirects = -> - [originalUrl].concat(redirects).join(" -> ") + if options.log + message = options._log.get('message') + + if redirects and redirects.length + message = [message].concat(redirects).join(" -> ") - options._log.set({message: indicateRedirects()}) + options._log.set({message: message}) consoleProps["Resolved Url"] = url consoleProps["Redirects"] = redirects diff --git a/packages/driver/src/cypress/error_messages.coffee b/packages/driver/src/cypress/error_messages.coffee index ec5dbc9a63db..bac9ff5f11c5 100644 --- a/packages/driver/src/cypress/error_messages.coffee +++ b/packages/driver/src/cypress/error_messages.coffee @@ -845,7 +845,15 @@ module.exports = { missing_preset: "#{cmd('viewport')} could not find a preset for: '{{preset}}'. Available presets are: {{presets}}" visit: - invalid_1st_arg: "#{cmd('visit')} must be called with a string as its 1st argument" + invalid_1st_arg: "#{cmd('visit')} must be called with a URL or an options object containing a URL as its 1st argument" + invalid_method: "#{cmd('visit')} was called with an invalid method: '{{method}}'. Method can only be GET or POST." + invalid_headers: "#{cmd('visit')} requires the 'headers' option to be an object." + no_duplicate_url: """ + #{cmd('visit')} must be called with only one URL. You specified two URLs: + + URL from the `options` object: {{optionsUrl}} + URL from the `url` parameter: {{url}} + """ cannot_visit_2nd_domain: """ #{cmd('visit')} failed because you are attempting to visit a second unique domain. diff --git a/packages/driver/test/cypress/integration/commands/navigation_spec.coffee b/packages/driver/test/cypress/integration/commands/navigation_spec.coffee index 2137a06e8b47..2caa1850f14e 100644 --- a/packages/driver/test/cypress/integration/commands/navigation_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/navigation_spec.coffee @@ -580,6 +580,37 @@ describe "src/cy/commands/navigation", -> expect(win.bar).to.not.exist expect(onLoad).not.to.have.been.called + it "can send headers", -> + cy.visit({ + url: "http://localhost:3500/dump-headers", + headers: { + "x-foo-baz": "bar-quux" + } + }) + cy.contains('"x-foo-baz":"bar-quux"') + + describe "can send a POST request", -> + it "automatically urlencoded using an object body", -> + cy.visit("http://localhost:3500/post-only", { + method: "POST", + body: { + bar: "baz" + } + }) + cy.contains("it worked!").contains("{\"bar\":\"baz\"}") + + it "with any string body and headers", -> + cy.visit("http://localhost:3500/post-only", { + method: "POST", + headers: { + "content-type": "application/json" + } + body: JSON.stringify({ + bar: "baz" + }) + }) + cy.contains("it worked!").contains("{\"bar\":\"baz\"}") + describe "when origins don't match", -> beforeEach -> Cypress.emit("test:before:run", { id: 888 }) @@ -856,6 +887,16 @@ describe "src/cy/commands/navigation", -> "http://localhost:3500/foo -> 1 -> 2" ) + it "indicates POST in the message", -> + cy.visit("http://localhost:3500/post-only", { + method: "POST" + }).then -> + lastLog = @lastLog + + expect(lastLog.get("message")).to.eq( + "POST http://localhost:3500/post-only" + ) + it "displays note in consoleProps when visiting the same page with a hash", -> cy.visit("http://localhost:3500/fixtures/generic.html#foo") .visit("http://localhost:3500/fixtures/generic.html#foo") @@ -934,11 +975,40 @@ describe "src/cy/commands/navigation", -> it "throws when url isnt a string", (done) -> cy.on "fail", (err) -> - expect(err.message).to.eq "cy.visit() must be called with a string as its 1st argument" + expect(err.message).to.eq "cy.visit() must be called with a URL or an options object containing a URL as its 1st argument" done() cy.visit() + it "throws when url is specified twice", (done) -> + cy.on "fail", (err) -> + expect(err.message).to.contain "cy.visit() must be called with only one URL. You specified two URLs" + done() + + cy.visit("http://foobarbaz", { + url: "http://foobarbaz" + }) + + it "throws when method is unsupported", (done) -> + cy.on "fail", (err) -> + expect(err.message).to.contain "cy.visit() was called with an invalid method: 'FOO'" + done() + + cy.visit({ + url: "http://foobarbaz", + method: "FOO" + }) + + it "throws when headers is not an object", (done) -> + cy.on "fail", (err) -> + expect(err.message).to.contain "cy.visit() requires the 'headers' option to be an object" + done() + + cy.visit({ + url: "http://foobarbaz", + headers: "quux" + }) + it "throws when attempting to visit a 2nd domain on different port", (done) -> cy.on "fail", (err) => lastLog = @lastLog @@ -1259,7 +1329,7 @@ describe "src/cy/commands/navigation", -> ## https://github.com/cypress-io/cypress/issues/3101 [{ - contentType: 'application/json', + contentType: 'application/json', pathName: 'json-content-type' }, { contentType: 'text/html; charset=utf-8,text/html', diff --git a/packages/driver/test/support/server.coffee b/packages/driver/test/support/server.coffee index 1fffcd127f84..f16ec81f5be4 100644 --- a/packages/driver/test/support/server.coffee +++ b/packages/driver/test/support/server.coffee @@ -72,6 +72,12 @@ niv.install("react-dom@15.6.1") res.setHeader('Content-Type', 'text/html; charset=utf-8,text/html') res.end("Test
Hello
") + app.post '/post-only', (req, res) -> + res.send("it worked!
request body:
#{JSON.stringify(req.body)}") + + app.get '/dump-headers', (req, res) -> + res.send("request headers:
#{JSON.stringify(req.headers)}") + app.get "/status-404", (req, res) -> res .status(404) diff --git a/packages/server/lib/server.coffee b/packages/server/lib/server.coffee index 021f7df6a5ef..cff31142c941 100644 --- a/packages/server/lib/server.coffee +++ b/packages/server/lib/server.coffee @@ -458,15 +458,19 @@ class Server @_remoteDomainName = previousState.domainName @_remoteVisitingUrl = previousState.visiting - request.sendStream(headers, automationRequest, { + # 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 - auth: options.auth gzip: false url: urlFile ? urlStr - headers: { + headers: _.assign({ accept: "text/html,*/*" - } + }, options.headers) followRedirect: (incomingRes) -> status = incomingRes.statusCode next = incomingRes.headers.location @@ -479,6 +483,10 @@ class Server return true }) + + debug('sending request with options %o', options) + + request.sendStream(headers, automationRequest, options) .then(handleReqStream) .catch(error)