diff --git a/core/engine.go b/core/engine.go
index daf5bed35052..1c5273572819 100644
--- a/core/engine.go
+++ b/core/engine.go
@@ -108,6 +108,20 @@ func NewEngine(
e.submetrics[parent] = append(e.submetrics[parent], sm)
}
+ // TODO: refactor this out of here when https://github.com/loadimpact/k6/issues/1832 lands and
+ // there is a better way to enable a metric with tag
+ if opts.SystemTags.Has(stats.TagExpectedResponse) {
+ for _, name := range []string{
+ "http_req_duration{expected_response:true}",
+ } {
+ if _, ok := e.thresholds[name]; ok {
+ continue
+ }
+ parent, sm := stats.NewSubmetric(name)
+ e.submetrics[parent] = append(e.submetrics[parent], sm)
+ }
+ }
+
return e, nil
}
diff --git a/core/local/local_test.go b/core/local/local_test.go
index 382dd1a0028e..c86f687b8cd5 100644
--- a/core/local/local_test.go
+++ b/core/local/local_test.go
@@ -329,12 +329,13 @@ func TestExecutionSchedulerSystemTags(t *testing.T) {
}()
expCommonTrailTags := stats.IntoSampleTags(&map[string]string{
- "group": "",
- "method": "GET",
- "name": sr("HTTPBIN_IP_URL/"),
- "url": sr("HTTPBIN_IP_URL/"),
- "proto": "HTTP/1.1",
- "status": "200",
+ "group": "",
+ "method": "GET",
+ "name": sr("HTTPBIN_IP_URL/"),
+ "url": sr("HTTPBIN_IP_URL/"),
+ "proto": "HTTP/1.1",
+ "status": "200",
+ "expected_response": "true",
})
expTrailPVUTagsRaw := expCommonTrailTags.CloneTags()
expTrailPVUTagsRaw["scenario"] = "per_vu_test"
diff --git a/js/internal/modules/modules.go b/js/internal/modules/modules.go
index 058fe459ae0d..72cdf355052b 100644
--- a/js/internal/modules/modules.go
+++ b/js/internal/modules/modules.go
@@ -35,7 +35,17 @@ var (
func Get(name string) interface{} {
mx.RLock()
defer mx.RUnlock()
- return modules[name]
+ mod := modules[name]
+ if i, ok := mod.(PerTestModule); ok {
+ return i.NewVUModule()
+ }
+ return mod
+}
+
+// PerTestModule is a simple interface representing a per test module object that can be used to
+// make a per VU instance of the Module
+type PerTestModule interface {
+ NewVUModule() interface{}
}
// Register the given mod as a JavaScript module, available
diff --git a/js/modules/k6/http/file_test.go b/js/modules/k6/http/file_test.go
index 45f91a181246..da0f0e3001b0 100644
--- a/js/modules/k6/http/file_test.go
+++ b/js/modules/k6/http/file_test.go
@@ -80,7 +80,7 @@ func TestHTTPFile(t *testing.T) {
assert.Equal(t, tc.expErr, fmt.Sprintf("%s", val["value"]))
}()
}
- h := New()
+ h := new(GlobalHTTP).NewVUModule().(*HTTP)
ctx := common.WithRuntime(context.Background(), rt)
out := h.File(ctx, tc.input, tc.args...)
assert.Equal(t, tc.expected, out)
diff --git a/js/modules/k6/http/http.go b/js/modules/k6/http/http.go
index 8aa6cdab2dc0..3118e26cbd72 100644
--- a/js/modules/k6/http/http.go
+++ b/js/modules/k6/http/http.go
@@ -30,7 +30,7 @@ import (
)
func init() {
- modules.Register("k6/http", New())
+ modules.Register("k6/http", new(GlobalHTTP))
}
const (
@@ -46,6 +46,38 @@ const (
// ErrJarForbiddenInInitContext is used when a cookie jar was made in the init context
var ErrJarForbiddenInInitContext = common.NewInitContextError("Making cookie jars in the init context is not supported")
+// GlobalHTTP is a global HTTP module for a k6 instance/test run
+type GlobalHTTP struct{}
+
+var _ modules.PerTestModule = new(GlobalHTTP)
+
+// NewVUModule returns an HTTP instance for each VU
+func (g *GlobalHTTP) NewVUModule() interface{} { // this here needs to return interface{}
+ return &HTTP{ // change the below fields to be not writable or not fields
+ SSL_3_0: netext.SSL_3_0,
+ TLS_1_0: netext.TLS_1_0,
+ TLS_1_1: netext.TLS_1_1,
+ TLS_1_2: netext.TLS_1_2,
+ TLS_1_3: netext.TLS_1_3,
+ OCSP_STATUS_GOOD: netext.OCSP_STATUS_GOOD,
+ OCSP_STATUS_REVOKED: netext.OCSP_STATUS_REVOKED,
+ OCSP_STATUS_SERVER_FAILED: netext.OCSP_STATUS_SERVER_FAILED,
+ OCSP_STATUS_UNKNOWN: netext.OCSP_STATUS_UNKNOWN,
+ OCSP_REASON_UNSPECIFIED: netext.OCSP_REASON_UNSPECIFIED,
+ OCSP_REASON_KEY_COMPROMISE: netext.OCSP_REASON_KEY_COMPROMISE,
+ OCSP_REASON_CA_COMPROMISE: netext.OCSP_REASON_CA_COMPROMISE,
+ OCSP_REASON_AFFILIATION_CHANGED: netext.OCSP_REASON_AFFILIATION_CHANGED,
+ OCSP_REASON_SUPERSEDED: netext.OCSP_REASON_SUPERSEDED,
+ OCSP_REASON_CESSATION_OF_OPERATION: netext.OCSP_REASON_CESSATION_OF_OPERATION,
+ OCSP_REASON_CERTIFICATE_HOLD: netext.OCSP_REASON_CERTIFICATE_HOLD,
+ OCSP_REASON_REMOVE_FROM_CRL: netext.OCSP_REASON_REMOVE_FROM_CRL,
+ OCSP_REASON_PRIVILEGE_WITHDRAWN: netext.OCSP_REASON_PRIVILEGE_WITHDRAWN,
+ OCSP_REASON_AA_COMPROMISE: netext.OCSP_REASON_AA_COMPROMISE,
+
+ responseCallback: defaultExpectedStatuses.match,
+ }
+}
+
//nolint: golint
type HTTP struct {
SSL_3_0 string `js:"SSL_3_0"`
@@ -67,31 +99,8 @@ type HTTP struct {
OCSP_REASON_REMOVE_FROM_CRL string `js:"OCSP_REASON_REMOVE_FROM_CRL"`
OCSP_REASON_PRIVILEGE_WITHDRAWN string `js:"OCSP_REASON_PRIVILEGE_WITHDRAWN"`
OCSP_REASON_AA_COMPROMISE string `js:"OCSP_REASON_AA_COMPROMISE"`
-}
-func New() *HTTP {
- //TODO: move this as an anonymous struct somewhere...
- return &HTTP{
- SSL_3_0: netext.SSL_3_0,
- TLS_1_0: netext.TLS_1_0,
- TLS_1_1: netext.TLS_1_1,
- TLS_1_2: netext.TLS_1_2,
- TLS_1_3: netext.TLS_1_3,
- OCSP_STATUS_GOOD: netext.OCSP_STATUS_GOOD,
- OCSP_STATUS_REVOKED: netext.OCSP_STATUS_REVOKED,
- OCSP_STATUS_SERVER_FAILED: netext.OCSP_STATUS_SERVER_FAILED,
- OCSP_STATUS_UNKNOWN: netext.OCSP_STATUS_UNKNOWN,
- OCSP_REASON_UNSPECIFIED: netext.OCSP_REASON_UNSPECIFIED,
- OCSP_REASON_KEY_COMPROMISE: netext.OCSP_REASON_KEY_COMPROMISE,
- OCSP_REASON_CA_COMPROMISE: netext.OCSP_REASON_CA_COMPROMISE,
- OCSP_REASON_AFFILIATION_CHANGED: netext.OCSP_REASON_AFFILIATION_CHANGED,
- OCSP_REASON_SUPERSEDED: netext.OCSP_REASON_SUPERSEDED,
- OCSP_REASON_CESSATION_OF_OPERATION: netext.OCSP_REASON_CESSATION_OF_OPERATION,
- OCSP_REASON_CERTIFICATE_HOLD: netext.OCSP_REASON_CERTIFICATE_HOLD,
- OCSP_REASON_REMOVE_FROM_CRL: netext.OCSP_REASON_REMOVE_FROM_CRL,
- OCSP_REASON_PRIVILEGE_WITHDRAWN: netext.OCSP_REASON_PRIVILEGE_WITHDRAWN,
- OCSP_REASON_AA_COMPROMISE: netext.OCSP_REASON_AA_COMPROMISE,
- }
+ responseCallback func(int) bool
}
func (*HTTP) XCookieJar(ctx *context.Context) *HTTPCookieJar {
diff --git a/js/modules/k6/http/http_test.go b/js/modules/k6/http/http_test.go
index a7f0d81b385e..f62d802b2e68 100644
--- a/js/modules/k6/http/http_test.go
+++ b/js/modules/k6/http/http_test.go
@@ -34,7 +34,7 @@ import (
func TestTagURL(t *testing.T) {
rt := goja.New()
rt.SetFieldNameMapper(common.FieldNameMapper{})
- rt.Set("http", common.Bind(rt, New(), nil))
+ rt.Set("http", common.Bind(rt, new(GlobalHTTP).NewVUModule(), nil))
testdata := map[string]struct{ u, n string }{
`http://localhost/anything/`: {"http://localhost/anything/", "http://localhost/anything/"},
diff --git a/js/modules/k6/http/request.go b/js/modules/k6/http/request.go
index d4a27b71b684..22f18ab9dace 100644
--- a/js/modules/k6/http/request.go
+++ b/js/modules/k6/http/request.go
@@ -113,7 +113,7 @@ func (h *HTTP) Request(ctx context.Context, method string, url goja.Value, args
if err != nil {
return nil, err
}
- return responseFromHttpext(resp), nil
+ return h.responseFromHttpext(resp), nil
}
//TODO break this function up
@@ -134,12 +134,14 @@ func (h *HTTP) parseRequest(
URL: reqURL.GetURL(),
Header: make(http.Header),
},
- Timeout: 60 * time.Second,
- Throw: state.Options.Throw.Bool,
- Redirects: state.Options.MaxRedirects,
- Cookies: make(map[string]*httpext.HTTPRequestCookie),
- Tags: make(map[string]string),
+ Timeout: 60 * time.Second,
+ Throw: state.Options.Throw.Bool,
+ Redirects: state.Options.MaxRedirects,
+ Cookies: make(map[string]*httpext.HTTPRequestCookie),
+ Tags: make(map[string]string),
+ ResponseCallback: h.responseCallback,
}
+
if state.Options.DiscardResponseBodies.Bool {
result.ResponseType = httpext.ResponseTypeNone
} else {
@@ -349,6 +351,15 @@ func (h *HTTP) parseRequest(
return nil, err
}
result.ResponseType = responseType
+ case "responseCallback":
+ v := params.Get(k).Export()
+ if v == nil {
+ result.ResponseCallback = nil
+ } else if c, ok := v.(*expectedStatuses); ok {
+ result.ResponseCallback = c.match
+ } else {
+ return nil, fmt.Errorf("unsupported responseCallback")
+ }
}
}
}
@@ -377,7 +388,7 @@ func (h *HTTP) prepareBatchArray(
ParsedHTTPRequest: parsedReq,
Response: response,
}
- results[i] = &Response{response}
+ results[i] = h.responseFromHttpext(response)
}
return batchReqs, results, nil
@@ -401,7 +412,7 @@ func (h *HTTP) prepareBatchObject(
ParsedHTTPRequest: parsedReq,
Response: response,
}
- results[key] = &Response{response}
+ results[key] = h.responseFromHttpext(response)
i++
}
diff --git a/js/modules/k6/http/request_test.go b/js/modules/k6/http/request_test.go
index dc7cb518b309..12ae3d201ac0 100644
--- a/js/modules/k6/http/request_test.go
+++ b/js/modules/k6/http/request_test.go
@@ -81,6 +81,7 @@ func TestRunES6String(t *testing.T) {
})
}
+// TODO replace this with the Single version
func assertRequestMetricsEmitted(t *testing.T, sampleContainers []stats.SampleContainer, method, url, name string, status int, group string) {
if name == "" {
name = url
@@ -130,6 +131,29 @@ func assertRequestMetricsEmitted(t *testing.T, sampleContainers []stats.SampleCo
assert.True(t, seenReceiving, "url %s didn't emit Receiving", url)
}
+func assertRequestMetricsEmittedSingle(t *testing.T, sampleContainer stats.SampleContainer, expectedTags map[string]string, metrics []*stats.Metric, callback func(sample stats.Sample)) {
+ t.Helper()
+
+ metricMap := make(map[string]bool, len(metrics))
+ for _, m := range metrics {
+ metricMap[m.Name] = false
+ }
+ for _, sample := range sampleContainer.GetSamples() {
+ tags := sample.Tags.CloneTags()
+ v, ok := metricMap[sample.Metric.Name]
+ assert.True(t, ok, "unexpected metric %s", sample.Metric.Name)
+ assert.False(t, v, "second metric %s", sample.Metric.Name)
+ metricMap[sample.Metric.Name] = true
+ assert.EqualValues(t, expectedTags, tags, "%s", tags)
+ if callback != nil {
+ callback(sample)
+ }
+ }
+ for k, v := range metricMap {
+ assert.True(t, v, "didn't emit %s", k)
+ }
+}
+
func newRuntime(
t testing.TB,
) (*httpmultibin.HTTPMultiBin, *lib.State, chan stats.SampleContainer, *goja.Runtime, *context.Context) {
@@ -169,7 +193,7 @@ func newRuntime(
ctx := new(context.Context)
*ctx = lib.WithState(tb.Context, state)
*ctx = common.WithRuntime(*ctx, rt)
- rt.Set("http", common.Bind(rt, New(), ctx))
+ rt.Set("http", common.Bind(rt, new(GlobalHTTP).NewVUModule(), ctx))
return tb, state, samples, rt, ctx
}
@@ -1945,26 +1969,28 @@ func TestRedirectMetricTags(t *testing.T) {
checkTags := func(sc stats.SampleContainer, expTags map[string]string) {
allSamples := sc.GetSamples()
- assert.Len(t, allSamples, 8)
+ assert.Len(t, allSamples, 9)
for _, s := range allSamples {
assert.Equal(t, expTags, s.Tags.CloneTags())
}
}
expPOSTtags := map[string]string{
- "group": "",
- "method": "POST",
- "url": sr("HTTPBIN_URL/redirect/post"),
- "name": sr("HTTPBIN_URL/redirect/post"),
- "status": "301",
- "proto": "HTTP/1.1",
+ "group": "",
+ "method": "POST",
+ "url": sr("HTTPBIN_URL/redirect/post"),
+ "name": sr("HTTPBIN_URL/redirect/post"),
+ "status": "301",
+ "proto": "HTTP/1.1",
+ "expected_response": "true",
}
expGETtags := map[string]string{
- "group": "",
- "method": "GET",
- "url": sr("HTTPBIN_URL/get"),
- "name": sr("HTTPBIN_URL/get"),
- "status": "200",
- "proto": "HTTP/1.1",
+ "group": "",
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/get"),
+ "name": sr("HTTPBIN_URL/get"),
+ "status": "200",
+ "proto": "HTTP/1.1",
+ "expected_response": "true",
}
checkTags(<-samples, expPOSTtags)
checkTags(<-samples, expGETtags)
diff --git a/js/modules/k6/http/response.go b/js/modules/k6/http/response.go
index 8deb1b2462ca..34506d7225c2 100644
--- a/js/modules/k6/http/response.go
+++ b/js/modules/k6/http/response.go
@@ -36,11 +36,11 @@ import (
// Response is a representation of an HTTP response to be returned to the goja VM
type Response struct {
*httpext.Response `js:"-"`
+ h *HTTP
}
-func responseFromHttpext(resp *httpext.Response) *Response {
- res := Response{resp}
- return &res
+func (h *HTTP) responseFromHttpext(resp *httpext.Response) *Response {
+ return &Response{Response: resp, h: h}
}
// JSON parses the body of a response as json and returns it to the goja VM
@@ -159,9 +159,9 @@ func (res *Response) SubmitForm(args ...goja.Value) (*Response, error) {
q.Add(k, v.String())
}
requestURL.RawQuery = q.Encode()
- return New().Request(res.GetCtx(), requestMethod, rt.ToValue(requestURL.String()), goja.Null(), requestParams)
+ return res.h.Request(res.GetCtx(), requestMethod, rt.ToValue(requestURL.String()), goja.Null(), requestParams)
}
- return New().Request(res.GetCtx(), requestMethod, rt.ToValue(requestURL.String()), rt.ToValue(values), requestParams)
+ return res.h.Request(res.GetCtx(), requestMethod, rt.ToValue(requestURL.String()), rt.ToValue(values), requestParams)
}
// ClickLink parses the body as an html, looks for a link and than makes a request as if the link was
@@ -202,5 +202,5 @@ func (res *Response) ClickLink(args ...goja.Value) (*Response, error) {
}
requestURL := responseURL.ResolveReference(hrefURL)
- return New().Get(res.GetCtx(), rt.ToValue(requestURL.String()), requestParams)
+ return res.h.Get(res.GetCtx(), rt.ToValue(requestURL.String()), requestParams)
}
diff --git a/js/modules/k6/http/response_callback.go b/js/modules/k6/http/response_callback.go
new file mode 100644
index 000000000000..cdcb7f746631
--- /dev/null
+++ b/js/modules/k6/http/response_callback.go
@@ -0,0 +1,117 @@
+/*
+ *
+ * k6 - a next-generation load testing tool
+ * Copyright (C) 2021 Load Impact
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package http
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/dop251/goja"
+ "github.com/loadimpact/k6/js/common"
+)
+
+//nolint:gochecknoglobals
+var defaultExpectedStatuses = expectedStatuses{
+ minmax: [][2]int{{200, 399}},
+}
+
+// expectedStatuses is specifically totally unexported so it can't be used for anything else but
+// SetResponseCallback and nothing can be done from the js side to modify it or make an instance of
+// it except using ExpectedStatuses
+type expectedStatuses struct {
+ minmax [][2]int
+ exact []int
+}
+
+func (e expectedStatuses) match(status int) bool {
+ for _, v := range e.exact {
+ if v == status {
+ return true
+ }
+ }
+
+ for _, v := range e.minmax {
+ if v[0] <= status && status <= v[1] {
+ return true
+ }
+ }
+ return false
+}
+
+// ExpectedStatuses returns expectedStatuses object based on the provided arguments.
+// The arguments must be either integers or object of `{min: , max: }`
+// kind. The "integer"ness is checked by the Number.isInteger.
+func (*HTTP) ExpectedStatuses(ctx context.Context, args ...goja.Value) *expectedStatuses { //nolint: golint
+ rt := common.GetRuntime(ctx)
+
+ if len(args) == 0 {
+ common.Throw(rt, errors.New("no arguments"))
+ }
+ var result expectedStatuses
+
+ jsIsInt, _ := goja.AssertFunction(rt.GlobalObject().Get("Number").ToObject(rt).Get("isInteger"))
+ isInt := func(a goja.Value) bool {
+ v, err := jsIsInt(goja.Undefined(), a)
+ return err == nil && v.ToBoolean()
+ }
+
+ errMsg := "argument number %d to expectedStatuses was neither an integer nor an object like {min:100, max:329}"
+ for i, arg := range args {
+ o := arg.ToObject(rt)
+ if o == nil {
+ common.Throw(rt, fmt.Errorf(errMsg, i+1))
+ }
+
+ if isInt(arg) {
+ result.exact = append(result.exact, int(o.ToInteger()))
+ } else {
+ min := o.Get("min")
+ max := o.Get("max")
+ if min == nil || max == nil {
+ common.Throw(rt, fmt.Errorf(errMsg, i+1))
+ }
+ if !(isInt(min) && isInt(max)) {
+ common.Throw(rt, fmt.Errorf("both min and max need to be integers for argument number %d", i+1))
+ }
+
+ result.minmax = append(result.minmax, [2]int{int(min.ToInteger()), int(max.ToInteger())})
+ }
+ }
+ return &result
+}
+
+// SetResponseCallback sets the responseCallback to the value provided. Supported values are
+// expectedStatuses object or a `null` which means that metrics shouldn't be tagged as failed and
+// `http_req_failed` should not be emitted - the behaviour previous to this
+func (h *HTTP) SetResponseCallback(ctx context.Context, val goja.Value) {
+ if val != nil && !goja.IsNull(val) {
+ // This is done this way as ExportTo exports functions to empty structs without an error
+ if es, ok := val.Export().(*expectedStatuses); ok {
+ h.responseCallback = es.match
+ } else {
+ //nolint:golint
+ common.Throw(common.GetRuntime(ctx), fmt.Errorf("unsupported argument, expected http.expectedStatuses"))
+ }
+ } else {
+ h.responseCallback = nil
+ }
+}
diff --git a/js/modules/k6/http/response_callback_test.go b/js/modules/k6/http/response_callback_test.go
new file mode 100644
index 000000000000..1d0d0398eb7c
--- /dev/null
+++ b/js/modules/k6/http/response_callback_test.go
@@ -0,0 +1,562 @@
+/*
+ *
+ * k6 - a next-generation load testing tool
+ * Copyright (C) 2021 Load Impact
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package http
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "testing"
+
+ "github.com/dop251/goja"
+ "github.com/loadimpact/k6/js/common"
+ "github.com/loadimpact/k6/lib"
+ "github.com/loadimpact/k6/lib/metrics"
+ "github.com/loadimpact/k6/stats"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestExpectedStatuses(t *testing.T) {
+ t.Parallel()
+ rt := goja.New()
+ rt.SetFieldNameMapper(common.FieldNameMapper{})
+ ctx := context.Background()
+
+ ctx = common.WithRuntime(ctx, rt)
+ rt.Set("http", common.Bind(rt, new(GlobalHTTP).NewVUModule(), &ctx))
+ cases := map[string]struct {
+ code, err string
+ expected expectedStatuses
+ }{
+ "good example": {
+ expected: expectedStatuses{exact: []int{200, 300}, minmax: [][2]int{{200, 300}}},
+ code: `(http.expectedStatuses(200, 300, {min: 200, max:300}))`,
+ },
+
+ "strange example": {
+ expected: expectedStatuses{exact: []int{200, 300}, minmax: [][2]int{{200, 300}}},
+ code: `(http.expectedStatuses(200, 300, {min: 200, max:300, other: "attribute"}))`,
+ },
+
+ "string status code": {
+ code: `(http.expectedStatuses(200, "300", {min: 200, max:300}))`,
+ err: "argument number 2 to expectedStatuses was neither an integer nor an object like {min:100, max:329}",
+ },
+
+ "string max status code": {
+ code: `(http.expectedStatuses(200, 300, {min: 200, max:"300"}))`,
+ err: "both min and max need to be integers for argument number 3",
+ },
+ "float status code": {
+ err: "argument number 2 to expectedStatuses was neither an integer nor an object like {min:100, max:329}",
+ code: `(http.expectedStatuses(200, 300.5, {min: 200, max:300}))`,
+ },
+
+ "float max status code": {
+ err: "both min and max need to be integers for argument number 3",
+ code: `(http.expectedStatuses(200, 300, {min: 200, max:300.5}))`,
+ },
+ "no arguments": {
+ code: `(http.expectedStatuses())`,
+ err: "no arguments",
+ },
+ }
+
+ for name, testCase := range cases {
+ name, testCase := name, testCase
+ t.Run(name, func(t *testing.T) {
+ val, err := rt.RunString(testCase.code)
+ if testCase.err == "" {
+ require.NoError(t, err)
+ got := new(expectedStatuses)
+ err = rt.ExportTo(val, &got)
+ require.NoError(t, err)
+ require.Equal(t, testCase.expected, *got)
+ return // the t.Run
+ }
+
+ require.Error(t, err)
+ exc := err.(*goja.Exception)
+ require.Contains(t, exc.Error(), testCase.err)
+ })
+ }
+}
+
+type expectedSample struct {
+ tags map[string]string
+ metrics []*stats.Metric
+}
+
+func TestResponseCallbackInAction(t *testing.T) {
+ t.Parallel()
+ tb, _, samples, rt, ctx := newRuntime(t)
+ defer tb.Cleanup()
+ sr := tb.Replacer.Replace
+ httpModule := new(GlobalHTTP).NewVUModule().(*HTTP)
+ rt.Set("http", common.Bind(rt, httpModule, ctx))
+
+ HTTPMetricsWithoutFailed := []*stats.Metric{
+ metrics.HTTPReqs,
+ metrics.HTTPReqBlocked,
+ metrics.HTTPReqConnecting,
+ metrics.HTTPReqDuration,
+ metrics.HTTPReqReceiving,
+ metrics.HTTPReqWaiting,
+ metrics.HTTPReqSending,
+ metrics.HTTPReqTLSHandshaking,
+ }
+
+ allHTTPMetrics := append(HTTPMetricsWithoutFailed, metrics.HTTPReqFailed)
+
+ testCases := map[string]struct {
+ code string
+ expectedSamples []expectedSample
+ }{
+ "basic": {
+ code: `http.request("GET", "HTTPBIN_URL/redirect/1");`,
+ expectedSamples: []expectedSample{
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/redirect/1"),
+ "name": sr("HTTPBIN_URL/redirect/1"),
+ "status": "302",
+ "group": "",
+ "expected_response": "true",
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/get"),
+ "name": sr("HTTPBIN_URL/get"),
+ "status": "200",
+ "group": "",
+ "expected_response": "true",
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ },
+ },
+ "overwrite per request": {
+ code: `
+ http.setResponseCallback(http.expectedStatuses(200));
+ res = http.request("GET", "HTTPBIN_URL/redirect/1");
+ `,
+ expectedSamples: []expectedSample{
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/redirect/1"),
+ "name": sr("HTTPBIN_URL/redirect/1"),
+ "status": "302",
+ "group": "",
+ "expected_response": "false", // this is on purpose
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/get"),
+ "name": sr("HTTPBIN_URL/get"),
+ "status": "200",
+ "group": "",
+ "expected_response": "true",
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ },
+ },
+
+ "global overwrite": {
+ code: `http.request("GET", "HTTPBIN_URL/redirect/1", null, {responseCallback: http.expectedStatuses(200)});`,
+ expectedSamples: []expectedSample{
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/redirect/1"),
+ "name": sr("HTTPBIN_URL/redirect/1"),
+ "status": "302",
+ "group": "",
+ "expected_response": "false", // this is on purpose
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/get"),
+ "name": sr("HTTPBIN_URL/get"),
+ "status": "200",
+ "group": "",
+ "expected_response": "true",
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ },
+ },
+ "per request overwrite with null": {
+ code: `http.request("GET", "HTTPBIN_URL/redirect/1", null, {responseCallback: null});`,
+ expectedSamples: []expectedSample{
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/redirect/1"),
+ "name": sr("HTTPBIN_URL/redirect/1"),
+ "status": "302",
+ "group": "",
+ "proto": "HTTP/1.1",
+ },
+ metrics: HTTPMetricsWithoutFailed,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/get"),
+ "name": sr("HTTPBIN_URL/get"),
+ "status": "200",
+ "group": "",
+ "proto": "HTTP/1.1",
+ },
+ metrics: HTTPMetricsWithoutFailed,
+ },
+ },
+ },
+ "global overwrite with null": {
+ code: `
+ http.setResponseCallback(null);
+ res = http.request("GET", "HTTPBIN_URL/redirect/1");
+ `,
+ expectedSamples: []expectedSample{
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/redirect/1"),
+ "name": sr("HTTPBIN_URL/redirect/1"),
+ "status": "302",
+ "group": "",
+ "proto": "HTTP/1.1",
+ },
+ metrics: HTTPMetricsWithoutFailed,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/get"),
+ "name": sr("HTTPBIN_URL/get"),
+ "status": "200",
+ "group": "",
+ "proto": "HTTP/1.1",
+ },
+ metrics: HTTPMetricsWithoutFailed,
+ },
+ },
+ },
+ }
+ for name, testCase := range testCases {
+ testCase := testCase
+ t.Run(name, func(t *testing.T) {
+ httpModule.responseCallback = defaultExpectedStatuses.match
+
+ _, err := rt.RunString(sr(testCase.code))
+ assert.NoError(t, err)
+ bufSamples := stats.GetBufferedSamples(samples)
+
+ reqsCount := 0
+ for _, container := range bufSamples {
+ for _, sample := range container.GetSamples() {
+ if sample.Metric.Name == "http_reqs" {
+ reqsCount++
+ }
+ }
+ }
+
+ require.Equal(t, len(testCase.expectedSamples), reqsCount)
+
+ for i, expectedSample := range testCase.expectedSamples {
+ assertRequestMetricsEmittedSingle(t, bufSamples[i], expectedSample.tags, expectedSample.metrics, nil)
+ }
+ })
+ }
+}
+
+func TestResponseCallbackBatch(t *testing.T) {
+ t.Parallel()
+ tb, _, samples, rt, ctx := newRuntime(t)
+ defer tb.Cleanup()
+ sr := tb.Replacer.Replace
+ httpModule := new(GlobalHTTP).NewVUModule().(*HTTP)
+ rt.Set("http", common.Bind(rt, httpModule, ctx))
+
+ HTTPMetricsWithoutFailed := []*stats.Metric{
+ metrics.HTTPReqs,
+ metrics.HTTPReqBlocked,
+ metrics.HTTPReqConnecting,
+ metrics.HTTPReqDuration,
+ metrics.HTTPReqReceiving,
+ metrics.HTTPReqWaiting,
+ metrics.HTTPReqSending,
+ metrics.HTTPReqTLSHandshaking,
+ }
+
+ allHTTPMetrics := append(HTTPMetricsWithoutFailed, metrics.HTTPReqFailed)
+ // IMPORTANT: the tests here depend on the fact that the url they hit can be ordered in the same
+ // order as the expectedSamples even if they are made concurrently
+ testCases := map[string]struct {
+ code string
+ expectedSamples []expectedSample
+ }{
+ "basic": {
+ code: `
+ http.batch([["GET", "HTTPBIN_URL/status/200", null, {responseCallback: null}],
+ ["GET", "HTTPBIN_URL/status/201"],
+ ["GET", "HTTPBIN_URL/status/202", null, {responseCallback: http.expectedStatuses(4)}],
+ ["GET", "HTTPBIN_URL/status/405", null, {responseCallback: http.expectedStatuses(405)}],
+ ]);`,
+ expectedSamples: []expectedSample{
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/status/200"),
+ "name": sr("HTTPBIN_URL/status/200"),
+ "status": "200",
+ "group": "",
+ "proto": "HTTP/1.1",
+ },
+ metrics: HTTPMetricsWithoutFailed,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/status/201"),
+ "name": sr("HTTPBIN_URL/status/201"),
+ "status": "201",
+ "group": "",
+ "expected_response": "true",
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/status/202"),
+ "name": sr("HTTPBIN_URL/status/202"),
+ "status": "202",
+ "group": "",
+ "expected_response": "false",
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ {
+ tags: map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/status/405"),
+ "name": sr("HTTPBIN_URL/status/405"),
+ "status": "405",
+ "error_code": "1405",
+ "group": "",
+ "expected_response": "true",
+ "proto": "HTTP/1.1",
+ },
+ metrics: allHTTPMetrics,
+ },
+ },
+ },
+ }
+ for name, testCase := range testCases {
+ testCase := testCase
+ t.Run(name, func(t *testing.T) {
+ httpModule.responseCallback = defaultExpectedStatuses.match
+
+ _, err := rt.RunString(sr(testCase.code))
+ assert.NoError(t, err)
+ bufSamples := stats.GetBufferedSamples(samples)
+
+ reqsCount := 0
+ for _, container := range bufSamples {
+ for _, sample := range container.GetSamples() {
+ if sample.Metric.Name == "http_reqs" {
+ reqsCount++
+ }
+ }
+ }
+ sort.Slice(bufSamples, func(i, j int) bool {
+ iURL, _ := bufSamples[i].GetSamples()[0].Tags.Get("url")
+ jURL, _ := bufSamples[j].GetSamples()[0].Tags.Get("url")
+ return iURL < jURL
+ })
+
+ require.Equal(t, len(testCase.expectedSamples), reqsCount)
+
+ for i, expectedSample := range testCase.expectedSamples {
+ assertRequestMetricsEmittedSingle(t, bufSamples[i], expectedSample.tags, expectedSample.metrics, nil)
+ }
+ })
+ }
+}
+
+func TestResponseCallbackInActionWithoutPassedTag(t *testing.T) {
+ t.Parallel()
+ tb, state, samples, rt, ctx := newRuntime(t)
+ defer tb.Cleanup()
+ sr := tb.Replacer.Replace
+ allHTTPMetrics := []*stats.Metric{
+ metrics.HTTPReqs,
+ metrics.HTTPReqFailed,
+ metrics.HTTPReqBlocked,
+ metrics.HTTPReqConnecting,
+ metrics.HTTPReqDuration,
+ metrics.HTTPReqReceiving,
+ metrics.HTTPReqSending,
+ metrics.HTTPReqWaiting,
+ metrics.HTTPReqTLSHandshaking,
+ }
+ deleteSystemTag(state, stats.TagExpectedResponse.String())
+ httpModule := new(GlobalHTTP).NewVUModule().(*HTTP)
+ rt.Set("http", common.Bind(rt, httpModule, ctx))
+
+ _, err := rt.RunString(sr(`http.request("GET", "HTTPBIN_URL/redirect/1", null, {responseCallback: http.expectedStatuses(200)});`))
+ assert.NoError(t, err)
+ bufSamples := stats.GetBufferedSamples(samples)
+
+ reqsCount := 0
+ for _, container := range bufSamples {
+ for _, sample := range container.GetSamples() {
+ if sample.Metric.Name == "http_reqs" {
+ reqsCount++
+ }
+ }
+ }
+
+ require.Equal(t, 2, reqsCount)
+
+ tags := map[string]string{
+ "method": "GET",
+ "url": sr("HTTPBIN_URL/redirect/1"),
+ "name": sr("HTTPBIN_URL/redirect/1"),
+ "status": "302",
+ "group": "",
+ "proto": "HTTP/1.1",
+ }
+ assertRequestMetricsEmittedSingle(t, bufSamples[0], tags, allHTTPMetrics, func(sample stats.Sample) {
+ if sample.Metric.Name == metrics.HTTPReqFailed.Name {
+ require.EqualValues(t, sample.Value, 1)
+ }
+ })
+ tags["url"] = sr("HTTPBIN_URL/get")
+ tags["name"] = tags["url"]
+ tags["status"] = "200"
+ assertRequestMetricsEmittedSingle(t, bufSamples[1], tags, allHTTPMetrics, func(sample stats.Sample) {
+ if sample.Metric.Name == metrics.HTTPReqFailed.Name {
+ require.EqualValues(t, sample.Value, 0)
+ }
+ })
+}
+
+func TestDigestWithResponseCallback(t *testing.T) {
+ t.Parallel()
+ tb, _, samples, rt, ctx := newRuntime(t)
+ defer tb.Cleanup()
+
+ httpModule := new(GlobalHTTP).NewVUModule().(*HTTP)
+ rt.Set("http", common.Bind(rt, httpModule, ctx))
+
+ urlWithCreds := tb.Replacer.Replace(
+ "http://testuser:testpwd@HTTPBIN_IP:HTTPBIN_PORT/digest-auth/auth/testuser/testpwd",
+ )
+
+ allHTTPMetrics := []*stats.Metric{
+ metrics.HTTPReqs,
+ metrics.HTTPReqFailed,
+ metrics.HTTPReqBlocked,
+ metrics.HTTPReqConnecting,
+ metrics.HTTPReqDuration,
+ metrics.HTTPReqReceiving,
+ metrics.HTTPReqSending,
+ metrics.HTTPReqWaiting,
+ metrics.HTTPReqTLSHandshaking,
+ }
+ _, err := rt.RunString(fmt.Sprintf(`
+ var res = http.get(%q, { auth: "digest" });
+ if (res.status !== 200) { throw new Error("wrong status: " + res.status); }
+ if (res.error_code !== 0) { throw new Error("wrong error code: " + res.error_code); }
+ `, urlWithCreds))
+ require.NoError(t, err)
+ bufSamples := stats.GetBufferedSamples(samples)
+
+ reqsCount := 0
+ for _, container := range bufSamples {
+ for _, sample := range container.GetSamples() {
+ if sample.Metric.Name == "http_reqs" {
+ reqsCount++
+ }
+ }
+ }
+
+ require.Equal(t, 2, reqsCount)
+
+ urlRaw := tb.Replacer.Replace(
+ "http://HTTPBIN_IP:HTTPBIN_PORT/digest-auth/auth/testuser/testpwd")
+
+ tags := map[string]string{
+ "method": "GET",
+ "url": urlRaw,
+ "name": urlRaw,
+ "status": "401",
+ "group": "",
+ "proto": "HTTP/1.1",
+ "expected_response": "true",
+ "error_code": "1401",
+ }
+ assertRequestMetricsEmittedSingle(t, bufSamples[0], tags, allHTTPMetrics, func(sample stats.Sample) {
+ if sample.Metric.Name == metrics.HTTPReqFailed.Name {
+ require.EqualValues(t, sample.Value, 0)
+ }
+ })
+ tags["status"] = "200"
+ delete(tags, "error_code")
+ assertRequestMetricsEmittedSingle(t, bufSamples[1], tags, allHTTPMetrics, func(sample stats.Sample) {
+ if sample.Metric.Name == metrics.HTTPReqFailed.Name {
+ require.EqualValues(t, sample.Value, 0)
+ }
+ })
+}
+
+func deleteSystemTag(state *lib.State, tag string) {
+ enabledTags := state.Options.SystemTags.Map()
+ delete(enabledTags, tag)
+ tagsList := make([]string, 0, len(enabledTags))
+ for k := range enabledTags {
+ tagsList = append(tagsList, k)
+ }
+ state.Options.SystemTags = stats.ToSystemTagSet(tagsList)
+}
diff --git a/js/modules/k6/http/response_test.go b/js/modules/k6/http/response_test.go
index 94a5ddac42ee..6b346e117544 100644
--- a/js/modules/k6/http/response_test.go
+++ b/js/modules/k6/http/response_test.go
@@ -55,6 +55,7 @@ const testGetFormHTML = `