diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..20e1ef185 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..163ae3833 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: + contents: read + +jobs: + + test: + name: Test + runs-on: ubuntu-latest + + strategy: + matrix: + go_version: [1.21.x] + steps: + + - name: Set up Go 1.x + uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.go_version }} + + - name: Check out code into the Go module directory + uses: actions/checkout@v3 + + - name: Test + run: go test -v ./... \ No newline at end of file diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 000000000..5cca0950d --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,71 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '24 11 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'go'] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/array.go b/array.go index 225916b2a..c1314d2f9 100644 --- a/array.go +++ b/array.go @@ -191,15 +191,115 @@ func (a *Array) Element(index int) *Value { return a.Value(index) } -// First returns a new Value instance for the first element of array. +// HasValue succeeds if array's value at the given index is equal to given value. // -// If given array is empty, First reports failure and returns empty -// (but non-nil) instance. +// Before comparison, both values are converted to canonical form. value should be +// map[string]interface{} or struct. // // Example: // -// array := NewArray(t, []interface{}{"foo", 123}) -// array.First().String().IsEqual("foo") +// array := NewArray(t, []interface{}{"foo", "123"}) +// array.HasValue(1, 123) +func (a *Array) HasValue(index int, value interface{}) *Array { + opChain := a.chain.enter("HasValue(%d)", index) + defer opChain.leave() + + if opChain.failed() { + return a + } + + if index < 0 || index >= len(a.value) { + opChain.fail(AssertionFailure{ + Type: AssertInRange, + Actual: &AssertionValue{index}, + Expected: &AssertionValue{AssertionRange{ + Min: 0, + Max: len(a.value) - 1, + }}, + Errors: []error{ + errors.New("expected: valid element index"), + }, + }) + return a + } + + expected, ok := canonValue(opChain, value) + if !ok { + return a + } + + if !reflect.DeepEqual(expected, a.value[index]) { + opChain.fail(AssertionFailure{ + Type: AssertEqual, + Actual: &AssertionValue{a.value[index]}, + Expected: &AssertionValue{value}, + Errors: []error{ + fmt.Errorf( + "expected: array value at index %d is equal to given value", + index), + }, + }) + return a + } + + return a +} + +// NotHasValue succeeds if array's value at the given index is not equal to given value. +// +// Before comparison, both values are converted to canonical form. value should be +// map[string]interface{} or struct. +// +// Example: +// +// array := NewArray(t, []interface{}{"foo", "123"}) +// array.NotHasValue(1, 234) +func (a *Array) NotHasValue(index int, value interface{}) *Array { + opChain := a.chain.enter("NotHasValue(%d)", index) + defer opChain.leave() + + if opChain.failed() { + return a + } + + if index < 0 || index >= len(a.value) { + opChain.fail(AssertionFailure{ + Type: AssertInRange, + Actual: &AssertionValue{index}, + Expected: &AssertionValue{AssertionRange{ + Min: 0, + Max: len(a.value) - 1, + }}, + Errors: []error{ + errors.New("expected: valid element index"), + }, + }) + return a + } + + expected, ok := canonValue(opChain, value) + if !ok { + return a + } + + if reflect.DeepEqual(expected, a.value[index]) { + opChain.fail(AssertionFailure{ + Type: AssertNotEqual, + Actual: &AssertionValue{a.value[index]}, + Expected: &AssertionValue{value}, + Errors: []error{ + fmt.Errorf( + "expected: array value at index %d is not equal to given value", + index), + }, + }) + return a + } + + return a +} + +// Deprecated: use Value or HasValue instead. func (a *Array) First() *Value { opChain := a.chain.enter("First()") defer opChain.leave() @@ -222,15 +322,7 @@ func (a *Array) First() *Value { return newValue(opChain, a.value[0]) } -// Last returns a new Value instance for the last element of array. -// -// If given array is empty, Last reports failure and returns empty -// (but non-nil) instance. -// -// Example: -// -// array := NewArray(t, []interface{}{"foo", 123}) -// array.Last().Number().IsEqual(123) +// Deprecated: use Value or HasValue instead. func (a *Array) Last() *Value { opChain := a.chain.enter("Last()") defer opChain.leave() @@ -261,7 +353,7 @@ func (a *Array) Last() *Value { // array := NewArray(t, strings) // // for index, value := range array.Iter() { -// value.String().IsEqual(strings[index]) +// value.String().IsEqual(strings[index]) // } func (a *Array) Iter() []Value { opChain := a.chain.enter("Iter()") @@ -1452,114 +1544,6 @@ func (a *Array) NotContainsOnly(values ...interface{}) *Array { return a } -// IsValueEqual succeeds if array's value at the given index is equal to given value. -// -// Before comparison, both values are converted to canonical form. value should be -// map[string]interface{} or struct. -// -// Example: -// -// array := NewArray(t, []interface{}{"foo", "123"}) -// array.IsValueEqual(1, 123) -func (a *Array) IsValueEqual(index int, value interface{}) *Array { - opChain := a.chain.enter("IsValueEqual(%d)", index) - defer opChain.leave() - - if opChain.failed() { - return a - } - - if index < 0 || index >= len(a.value) { - opChain.fail(AssertionFailure{ - Type: AssertInRange, - Actual: &AssertionValue{index}, - Expected: &AssertionValue{AssertionRange{ - Min: 0, - Max: len(a.value) - 1, - }}, - Errors: []error{ - errors.New("expected: valid element index"), - }, - }) - return a - } - - expected, ok := canonValue(opChain, value) - if !ok { - return a - } - - if !reflect.DeepEqual(expected, a.value[index]) { - opChain.fail(AssertionFailure{ - Type: AssertEqual, - Actual: &AssertionValue{a.value[index]}, - Expected: &AssertionValue{value}, - Errors: []error{ - fmt.Errorf( - "expected: array value at index %d is equal to given value", - index), - }, - }) - return a - } - - return a -} - -// NotValueEqual succeeds if array's value at the given index is not equal to given value. -// -// Before comparison, both values are converted to canonical form. value should be -// map[string]interface{} or struct. -// -// Example: -// -// array := NewArray(t, []interface{}{"foo", "123"}) -// array.NotValueEqual(1, 234) -func (a *Array) NotValueEqual(index int, value interface{}) *Array { - opChain := a.chain.enter("NotValueEqual(%d)", index) - defer opChain.leave() - - if opChain.failed() { - return a - } - - if index < 0 || index >= len(a.value) { - opChain.fail(AssertionFailure{ - Type: AssertInRange, - Actual: &AssertionValue{index}, - Expected: &AssertionValue{AssertionRange{ - Min: 0, - Max: len(a.value) - 1, - }}, - Errors: []error{ - errors.New("expected: valid element index"), - }, - }) - return a - } - - expected, ok := canonValue(opChain, value) - if !ok { - return a - } - - if reflect.DeepEqual(expected, a.value[index]) { - opChain.fail(AssertionFailure{ - Type: AssertNotEqual, - Actual: &AssertionValue{a.value[index]}, - Expected: &AssertionValue{value}, - Errors: []error{ - fmt.Errorf( - "expected: array value at index %d is not equal to given value", - index), - }, - }) - return a - } - - return a -} - // IsOrdered succeeds if every element is not less than the previous element // as defined on the given `less` comparator function. // For default, it will use built-in comparator function for each data type. diff --git a/assertion.go b/assertion.go index 6354a4368..d8dcb7707 100644 --- a/assertion.go +++ b/assertion.go @@ -134,6 +134,10 @@ type AssertionContext struct { // Environment shared between tests // Comes from Expect instance Environment *Environment + + // Whether reporter is known to output to testing.TB + // For example, true when reporter is testing.T or testify-based reporter. + TestingTB bool } // AssertionFailure provides detailed information about failed assertion. @@ -188,6 +192,9 @@ type AssertionFailure struct { // Allowed delta between actual and expected Delta *AssertionValue + + // Stacktrace of the failure + Stacktrace []StacktraceEntry } // AssertionValue holds expected or actual value diff --git a/binder.go b/binder.go index 580eae90d..1075b4046 100644 --- a/binder.go +++ b/binder.go @@ -27,7 +27,7 @@ type Binder struct { // Example: // // client := &http.Client{ -// Transport: NewBinder(handler), +// Transport: NewBinder(handler), // } func NewBinder(handler http.Handler) Binder { return Binder{Handler: handler} diff --git a/body_wrapper.go b/body_wrapper.go index d774cfce4..28b050225 100644 --- a/body_wrapper.go +++ b/body_wrapper.go @@ -3,36 +3,72 @@ package httpexpect import ( "bytes" "context" + "errors" "io" "io/ioutil" "runtime" "sync" ) -// Wrapper for request or response body reader +// Wrapper for request or response body reader. +// // Allows to read body multiple times using two approaches: // - use Read to read body contents and Rewind to restart reading from beginning // - use GetBody to get new reader for body contents +// +// When bodyWrapper is created, it does not read anything. Also, until anything is +// read, rewind operations are no-op. +// +// When the user starts reading body, bodyWrapper automatically copies retrieved +// content in memory. Then, when the body is fully read and Rewind is requested, +// it will close original body and switch to reading body from memory. +// +// If Rewind, GetBody, or Close is invoked before the body is fully read first time, +// bodyWrapper automatically performs full read. +// +// At any moment, the user can call DisableRewinds. In this case, Rewind and GetBody +// functionality is disabled, memory cache is cleared, and bodyWrapper switches to +// reading original body (if it's not fully read yet). +// +// bodyWrapper automatically creates finalizer that will close original body if the +// user never reads it fully or calls Closes. type bodyWrapper struct { - currReader io.Reader + // Protects all operations. + mu sync.Mutex + + // Original reader of HTTP response body. + httpReader io.ReadCloser + + // Cancellation function for original HTTP response. + // If set, called after HTTP response is fully read into memory. + httpCancelFunc context.CancelFunc - origReader io.ReadCloser - origBytes []byte + // Reader for HTTP response body stored in memory. + // Rewind() resets this reader to start from the beginning. + memReader io.Reader + // HTTP response body stored in memory. + memBytes []byte + + // Cached read and close errors. readErr error closeErr error - cancelFunc context.CancelFunc + // If true, Read will not store bytes in memory, and memBytes and memReader + // won't be used. + isRewindDisabled bool - isInitialized bool + // True means that HTTP response was fully read into memory already. + isFullyRead bool - mu sync.Mutex + // True means that a read operation of any type was called at least once. + isReadBefore bool } func newBodyWrapper(reader io.ReadCloser, cancelFunc context.CancelFunc) *bodyWrapper { bw := &bodyWrapper{ - origReader: reader, - cancelFunc: cancelFunc, + httpReader: reader, + httpCancelFunc: cancelFunc, } // Finalizer will close body if closeAndCancel was never called. @@ -41,112 +77,178 @@ func newBodyWrapper(reader io.ReadCloser, cancelFunc context.CancelFunc) *bodyWr return bw } -// Read body contents -func (bw *bodyWrapper) Read(p []byte) (n int, err error) { +// Read body contents. +func (bw *bodyWrapper) Read(p []byte) (int, error) { bw.mu.Lock() defer bw.mu.Unlock() - // Preserve original reader error - if bw.readErr != nil { - return 0, bw.readErr - } - - // Lazy initialization - if !bw.isInitialized { - if initErr := bw.initialize(); initErr != nil { - return 0, initErr - } - } - - if bw.currReader == nil { - bw.currReader = bytes.NewReader(bw.origBytes) + bw.isReadBefore = true + + if bw.isRewindDisabled && !bw.isFullyRead { + // Regular read from original HTTP response. + return bw.httpReader.Read(p) + } else if !bw.isFullyRead { + // Read from original HTTP response + store into memory. + return bw.httpReadNext(p) + } else { + // Read from memory. + return bw.memReadNext(p) } - return bw.currReader.Read(p) } -// Close body +// Close body. func (bw *bodyWrapper) Close() error { bw.mu.Lock() defer bw.mu.Unlock() + // Preserve original reader error. err := bw.closeErr // Rewind or GetBody may be called later, so be sure to - // read body into memory before closing - if !bw.isInitialized { - initErr := bw.initialize() - if initErr != nil { - err = initErr + // read body into memory before closing. + if !bw.isRewindDisabled && !bw.isFullyRead { + bw.isReadBefore = true + + if readErr := bw.httpReadFull(); readErr != nil { + err = readErr } } - // Close original reader + // Close original reader. closeErr := bw.closeAndCancel() if closeErr != nil { err = closeErr } + // Reset memory reader. + bw.memReader = bytes.NewReader(nil) + return err } -// Rewind reading to the beginning +// Rewind reading to the beginning. func (bw *bodyWrapper) Rewind() { bw.mu.Lock() defer bw.mu.Unlock() - // Until first read, rewind is no-op - if !bw.isInitialized { + // Rewind is no-op if disabled. + if bw.isRewindDisabled { + return + } + + // Rewind is no-op until first read operation. + if !bw.isReadBefore { return } - // Reset reader - bw.currReader = bytes.NewReader(bw.origBytes) + // If HTTP response is not fully read yet, do it now. + // If error occurs, it will be reported next read operation. + if !bw.isFullyRead { + _ = bw.httpReadFull() + } + + // Reset memory reader. + bw.memReader = bytes.NewReader(bw.memBytes) } -// Create new reader to retrieve body contents -// New reader always reads body from the beginning -// Does not affected by Rewind() +// Create new reader to retrieve body contents. +// New reader always reads body from the beginning. +// Does not affected by Rewind(). func (bw *bodyWrapper) GetBody() (io.ReadCloser, error) { bw.mu.Lock() defer bw.mu.Unlock() - // Preserve original reader error + bw.isReadBefore = true + + // Preserve original reader error. if bw.readErr != nil { return nil, bw.readErr } - // Lazy initialization - if !bw.isInitialized { - if initErr := bw.initialize(); initErr != nil { - return nil, initErr + // GetBody() requires rewinds to be enabled. + if bw.isRewindDisabled { + return nil, errors.New("rewinds are disabled, cannot get body") + } + + // If HTTP response is not fully read yet, do it now. + if !bw.isFullyRead { + if err := bw.httpReadFull(); err != nil { + return nil, err } } - return ioutil.NopCloser(bytes.NewReader(bw.origBytes)), nil + // Return fresh reader for memory chunk. + return ioutil.NopCloser(bytes.NewReader(bw.memBytes)), nil } -func (bw *bodyWrapper) initialize() error { - if !bw.isInitialized { - bw.isInitialized = true +// Disables storing body contents in memory and clears the cache. +func (bw *bodyWrapper) DisableRewinds() { + bw.mu.Lock() + defer bw.mu.Unlock() - if bw.origReader != nil { - bw.origBytes, bw.readErr = ioutil.ReadAll(bw.origReader) + bw.isRewindDisabled = true +} + +func (bw *bodyWrapper) memReadNext(p []byte) (int, error) { + n, err := bw.memReader.Read(p) + + if err == io.EOF && bw.readErr != nil { + err = bw.readErr + } + + return n, err +} - _ = bw.closeAndCancel() +func (bw *bodyWrapper) httpReadNext(p []byte) (int, error) { + n, err := bw.httpReader.Read(p) + + if n > 0 { + bw.memBytes = append(bw.memBytes, p[:n]...) + } + + if err != nil { + if err != io.EOF { + bw.readErr = err + } + if closeErr := bw.closeAndCancel(); closeErr != nil && err == io.EOF { + err = closeErr } + + // Switch to reading from memory. + bw.isFullyRead = true + bw.memReader = bytes.NewReader(nil) } - return bw.readErr + return n, err +} + +func (bw *bodyWrapper) httpReadFull() error { + b, err := ioutil.ReadAll(bw.httpReader) + + // Switch to reading from memory. + bw.isFullyRead = true + bw.memBytes = append(bw.memBytes, b...) + bw.memReader = bytes.NewReader(bw.memBytes[len(bw.memBytes)-len(b):]) + + if err != nil { + bw.readErr = err + } + + if closeErr := bw.closeAndCancel(); closeErr != nil && err == nil { + err = closeErr + } + + return err } func (bw *bodyWrapper) closeAndCancel() error { - if bw.origReader == nil && bw.cancelFunc == nil { + if bw.httpReader == nil && bw.httpCancelFunc == nil { return bw.closeErr } - if bw.origReader != nil { - err := bw.origReader.Close() - bw.origReader = nil + if bw.httpReader != nil { + err := bw.httpReader.Close() + bw.httpReader = nil if bw.readErr == nil { bw.readErr = err @@ -157,9 +259,9 @@ func (bw *bodyWrapper) closeAndCancel() error { } } - if bw.cancelFunc != nil { - bw.cancelFunc() - bw.cancelFunc = nil + if bw.httpCancelFunc != nil { + bw.httpCancelFunc() + bw.httpCancelFunc = nil } // Finalizer is not needed anymore. diff --git a/chain.go b/chain.go index cb54ae0a8..94fc11ee2 100644 --- a/chain.go +++ b/chain.go @@ -100,6 +100,13 @@ const ( flagFailedChildren // fail() was called on any child ) +type chainResult bool + +const ( + success chainResult = true + failure chainResult = false +) + // Construct chain using config. func newChainWithConfig(name string, config Config) *chain { config.validate() @@ -126,11 +133,13 @@ func newChainWithConfig(name string, config Config) *chain { c.context.Environment = newEnvironment(c) } + c.context.TestingTB = isTestingTB(c.handler) + return c } // Construct chain using DefaultAssertionHandler and provided Reporter. -func newChainWithDefaults(name string, reporter Reporter) *chain { +func newChainWithDefaults(name string, reporter Reporter, flag ...chainFlags) *chain { if reporter == nil { panic("Reporter is nil") } @@ -154,6 +163,12 @@ func newChainWithDefaults(name string, reporter Reporter) *chain { c.context.Environment = newEnvironment(c) + c.context.TestingTB = isTestingTB(c.handler) + + for _, f := range flag { + c.flags |= f + } + return c } @@ -257,6 +272,20 @@ func (c *chain) setResponse(resp *Response) { c.context.Response = resp } +// Set assertion handler +// Chain always overrides assertion handler with given one. +func (c *chain) setHandler(handler AssertionHandler) { + c.mu.Lock() + defer c.mu.Unlock() + + if chainValidation && c.state == stateLeaved { + panic("can't use chain after leave") + } + + c.handler = handler + c.context.TestingTB = isTestingTB(handler) +} + // Create chain clone. // Typically is called between enter() and leave(). func (c *chain) clone() *chain { @@ -420,6 +449,9 @@ func (c *chain) fail(failure AssertionFailure) { if c.severity == SeverityError { failure.IsFatal = true } + + failure.Stacktrace = stacktrace() + c.failure = &failure } @@ -439,50 +471,51 @@ func (c *chain) treeFailed() bool { return c.flags&(flagFailed|flagFailedChildren) != 0 } -// Set failure flag. +// Report failure unless chain has specified state. // For tests. -func (c *chain) setFailed() { +func (c *chain) assert(t testing.TB, result chainResult) { c.mu.Lock() defer c.mu.Unlock() - c.flags |= flagFailed -} - -// Clear failure flags. -// For tests. -func (c *chain) clearFailed() { - c.mu.Lock() - defer c.mu.Unlock() + switch result { + case success: + assert.Equal(t, chainFlags(0), c.flags&flagFailed, + "expected: chain is in success state") - c.flags &= ^(flagFailed | flagFailedChildren) + case failure: + assert.NotEqual(t, chainFlags(0), c.flags&flagFailed, + "expected: chain is in failure state") + } } -// Report failure unless chain is not failed. +// Report failure unless chain has specified flags. // For tests. -func (c *chain) assertNotFailed(t testing.TB) { +func (c *chain) assertFlags(t testing.TB, flags chainFlags) { c.mu.Lock() defer c.mu.Unlock() - assert.Equal(t, chainFlags(0), c.flags&flagFailed, - "expected: chain is not failed") + assert.Equal(t, flags, c.flags, + "expected: chain has specified flags") } -// Report failure unless chain is failed. +// Clear failure flags. // For tests. -func (c *chain) assertFailed(t testing.TB) { +func (c *chain) clear() { c.mu.Lock() defer c.mu.Unlock() - assert.NotEqual(t, chainFlags(0), c.flags&flagFailed, - "expected: chain is failed") + c.flags &= ^(flagFailed | flagFailedChildren) } -// Report failure unless chain has specified flags. -// For tests. -func (c *chain) assertFlags(t testing.TB, flags chainFlags) { - c.mu.Lock() - defer c.mu.Unlock() - - assert.Equal(t, flags, c.flags, - "expected: chain has specified flags") +// Whether handler outputs to testing.TB +func isTestingTB(in AssertionHandler) bool { + h, ok := in.(*DefaultAssertionHandler) + if !ok { + return false + } + switch h.Reporter.(type) { + case *AssertReporter, *RequireReporter, *FatalReporter, testing.TB: + return true + } + return false } diff --git a/environment.go b/environment.go index 9f2dc9dd4..aa303a758 100644 --- a/environment.go +++ b/environment.go @@ -2,8 +2,11 @@ package httpexpect import ( "errors" + "sort" "sync" "time" + + "github.com/gobwas/glob" ) // Environment provides a container for arbitrary data shared between tests. @@ -82,6 +85,24 @@ func (e *Environment) Delete(key string) { delete(e.data, key) } +// Clear will delete all key value pairs from the environment +// +// Example: +// +// env := NewEnvironment(t) +// env.Put("key1", 123) +// env.Put("key2", 456) +// env.Clear() +func (e *Environment) Clear() { + opChain := e.chain.enter("Clear()") + defer opChain.leave() + + e.mu.Lock() + defer e.mu.Unlock() + + e.data = make(map[string]interface{}) +} + // Has returns true if value exists in the environment. // // Example: @@ -424,6 +445,78 @@ func (e *Environment) GetTime(key string) time.Time { return casted } +// List returns a sorted slice of keys. +// +// Example: +// +// env := NewEnvironment(t) +// +// for _, key := range env.List() { +// ... +// } +func (e *Environment) List() []string { + opChain := e.chain.enter("List()") + defer opChain.leave() + + e.mu.RLock() + defer e.mu.RUnlock() + + keys := []string{} + + for key := range e.data { + keys = append(keys, key) + } + + sort.Slice(keys, func(i, j int) bool { + return keys[i] < keys[j] + }) + return keys +} + +// Glob accepts a glob pattern and returns a sorted slice of +// keys that match the pattern. +// +// If the pattern is invalid, reports failure and returns an +// empty slice. +// +// Example: +// +// env := NewEnvironment(t) +// +// for _, key := range env.Glob("foo.*") { +// ... +// } +func (e *Environment) Glob(pattern string) []string { + opChain := e.chain.enter("Glob(%q)", pattern) + defer opChain.leave() + + e.mu.RLock() + defer e.mu.RUnlock() + + glb, err := glob.Compile(pattern) + if err != nil { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected invalid glob pattern"), + }, + }) + return []string{} + } + + keys := []string{} + for key := range e.data { + if glb.Match(key) { + keys = append(keys, key) + } + } + + sort.Slice(keys, func(i, j int) bool { + return keys[i] < keys[j] + }) + return keys +} + func envValue(chain *chain, env map[string]interface{}, key string) (interface{}, bool) { v, ok := env[key] diff --git a/expect.go b/expect.go index 96374cb90..aab7fb8e0 100644 --- a/expect.go +++ b/expect.go @@ -3,8 +3,7 @@ // # Usage examples // // See example directory: -// - https://pkg.go.dev/github.com/gavv/httpexpect/_examples -// - https://github.com/gavv/httpexpect/tree/master/_examples +// - https://github.com/kataras/iris/tree/master/_examples // // # Communication mode // @@ -161,6 +160,8 @@ type Config struct { // (non-fatal / fatal failures using testify package) // - testing.T / FatalReporter // (non-fatal / fatal failures using standard testing package) + // - PanicReporter + // (failures that panic to be used in multithreaded tests) // - custom implementation Reporter Reporter @@ -284,6 +285,27 @@ type RequestFactory interface { NewRequest(method, url string, body io.Reader) (*http.Request, error) } +// RequestFactoryFunc is an adapter that allows a function +// to be used as the RequestFactory +// +// Example: +// +// e := httpexpect.WithConfig(httpexpect.Config{ +// RequestFactory: httpextect.RequestFactoryFunc( +// func(method string, url string, body io.Reader) (*http.Request, error) { +// // factory code here +// }), +// }) +type RequestFactoryFunc func( + method string, url string, body io.Reader, +) (*http.Request, error) + +func (f RequestFactoryFunc) NewRequest( + method string, url string, body io.Reader, +) (*http.Request, error) { + return f(method, url, body) +} + // Client is used to send http.Request and receive http.Response. // http.Client implements this interface. // @@ -299,6 +321,22 @@ type Client interface { Do(*http.Request) (*http.Response, error) } +// ClientFunc is an adapter that allows a function to be used as the Client +// +// Example: +// +// e := httpexpect.WithConfig(httpexpect.Config{ +// Client: httpextect.ClientFunc( +// func(req *http.Request) (*http.Response, error) { +// // client code here +// }), +// }) +type ClientFunc func(req *http.Request) (*http.Response, error) + +func (f ClientFunc) Do(req *http.Request) (*http.Response, error) { + return f(req) +} + // WebsocketDialer is used to establish websocket.Conn and receive http.Response // of handshake result. // websocket.Dialer implements this interface. @@ -309,8 +347,7 @@ type Client interface { // Example: // // e := httpexpect.WithConfig(httpexpect.Config{ -// BaseURL: "http://example.com", -// WebsocketDialer: httpexpect.NewWebsocketDialer(myHandler), +// WebsocketDialer: httpexpect.NewWebsocketDialer(myHandler), // }) type WebsocketDialer interface { // Dial establishes new Websocket connection and returns response @@ -318,14 +355,51 @@ type WebsocketDialer interface { Dial(url string, reqH http.Header) (*websocket.Conn, *http.Response, error) } +// WebsocketDialerFunc is an adapter that allows a function +// to be used as the WebsocketDialer +// +// Example: +// +// e := httpexpect.WithConfig(httpexpect.Config{ +// WebsocketDialer: httpextect.WebsocketDialerFunc( +// func(url string, reqH http.Header) (*websocket.Conn, *http.Response, error) { +// // dialer code here +// }), +// }) +type WebsocketDialerFunc func( + url string, reqH http.Header, +) (*websocket.Conn, *http.Response, error) + +func (f WebsocketDialerFunc) Dial( + url string, reqH http.Header, +) (*websocket.Conn, *http.Response, error) { + return f(url, reqH) +} + // Reporter is used to report failures. -// *testing.T, FatalReporter, AssertReporter, RequireReporter implement it. +// *testing.T, FatalReporter, AssertReporter, RequireReporter, PanicReporter implement it. type Reporter interface { // Errorf reports failure. // Allowed to return normally or terminate test using t.FailNow(). Errorf(message string, args ...interface{}) } +// ReporterFunc is an adapter that allows a function to be used as the Reporter +// +// Example: +// +// e := httpexpect.WithConfig(httpexpect.Config{ +// Reporter: httpextect.ReporterFunc( +// func(message string, args ...interface{}) { +// // reporter code here +// }), +// }) +type ReporterFunc func(message string, args ...interface{}) + +func (f ReporterFunc) Errorf(message string, args ...interface{}) { + f(message, args) +} + // Logger is used as output backend for Printer. // *testing.T implements this interface. type Logger interface { @@ -333,6 +407,25 @@ type Logger interface { Logf(fmt string, args ...interface{}) } +// LoggerFunc is an adapter that allows a function to be used as the Logger +// +// Example: +// +// e := httpexpect.WithConfig(httpexpect.Config{ +// Printers: []httpexpect.Printer{ +// httpexpect.NewCompactPrinter( +// httpextect.LoggerFunc( +// func(fmt string, args ...interface{}) { +// // logger code here +// })), +// }, +// }) +type LoggerFunc func(fmt string, args ...interface{}) + +func (f LoggerFunc) Logf(fmt string, args ...interface{}) { + f(fmt, args) +} + // TestingTB is a subset of testing.TB interface used by httpexpect. // You can use *testing.T or pass custom implementation. type TestingTB interface { @@ -374,11 +467,11 @@ func New(t LoggerReporter, baseURL string) *Expect { // Example: // // func TestSomething(t *testing.T) { -// e := httpexpect.Default(t, "http://example.com/") +// e := httpexpect.Default(t, "http://example.com/") // -// e.GET("/path"). -// Expect(). -// Status(http.StatusOK) +// e.GET("/path"). +// Expect(). +// Status(http.StatusOK) // } func Default(t TestingTB, baseURL string) *Expect { return WithConfig(Config{ @@ -399,23 +492,23 @@ func Default(t TestingTB, baseURL string) *Expect { // Example: // // func TestSomething(t *testing.T) { -// e := httpexpect.WithConfig(httpexpect.Config{ -// TestName: t.Name(), -// BaseURL: "http://example.com/", -// Client: &http.Client{ -// Transport: httpexpect.NewBinder(myHandler()), -// Jar: httpexpect.NewCookieJar(), -// }, -// Reporter: httpexpect.NewAssertReporter(t), -// Printers: []httpexpect.Printer{ -// httpexpect.NewCurlPrinter(t), -// httpexpect.NewDebugPrinter(t, true) -// }, -// }) -// -// e.GET("/path"). -// Expect(). -// Status(http.StatusOK) +// e := httpexpect.WithConfig(httpexpect.Config{ +// TestName: t.Name(), +// BaseURL: "http://example.com/", +// Client: &http.Client{ +// Transport: httpexpect.NewBinder(myHandler()), +// Jar: httpexpect.NewCookieJar(), +// }, +// Reporter: httpexpect.NewAssertReporter(t), +// Printers: []httpexpect.Printer{ +// httpexpect.NewCurlPrinter(t), +// httpexpect.NewDebugPrinter(t, true) +// }, +// }) +// +// e.GET("/path"). +// Expect(). +// Status(http.StatusOK) // } func WithConfig(config Config) *Expect { config = config.withDefaults() @@ -459,11 +552,11 @@ func (e *Expect) clone() *Expect { // e := httpexpect.Default(t, "http://example.com") // // token := e.POST("/login").WithForm(Login{"ford", "betelgeuse7"}). -// Expect(). -// Status(http.StatusOK).JSON().Object().Value("token").String().Raw() +// Expect(). +// Status(http.StatusOK).JSON().Object().Value("token").String().Raw() // // auth := e.Builder(func (req *httpexpect.Request) { -// req.WithHeader("Authorization", "Bearer "+token) +// req.WithHeader("Authorization", "Bearer "+token) // }) // // auth.GET("/restricted"). @@ -485,16 +578,16 @@ func (e *Expect) Builder(builder func(*Request)) *Expect { // e := httpexpect.Default(t, "http://example.com") // // m := e.Matcher(func (resp *httpexpect.Response) { -// resp.Header("API-Version").NotEmpty() +// resp.Header("API-Version").NotEmpty() // }) // // m.GET("/some-path"). -// Expect(). -// Status(http.StatusOK) +// Expect(). +// Status(http.StatusOK) // // m.GET("/bad-path"). -// Expect(). -// Status(http.StatusNotFound) +// Expect(). +// Status(http.StatusNotFound) func (e *Expect) Matcher(matcher func(*Response)) *Expect { ret := e.clone() diff --git a/formatter.go b/formatter.go index 9ba1d514f..6fe0dd281 100644 --- a/formatter.go +++ b/formatter.go @@ -3,12 +3,21 @@ package httpexpect import ( "bytes" "encoding/json" + "flag" "fmt" "math" + "net/http/httputil" + "os" + "path/filepath" + "regexp" "strconv" "strings" + "sync" + "testing" "text/template" + "github.com/fatih/color" + "github.com/mattn/go-isatty" "github.com/mitchellh/go-wordwrap" "github.com/sanity-io/litter" "github.com/yudai/gojsondiff" @@ -45,10 +54,28 @@ type DefaultFormatter struct { // Exclude diff from failure report. DisableDiffs bool + // Exclude HTTP request from failure report. + DisableRequests bool + + // Exclude HTTP response from failure report. + DisableResponses bool + + // Thousand separator. + // Default is DigitSeparatorUnderscore. + DigitSeparator DigitSeparator + // Float printing format. // Default is FloatFormatAuto. FloatFormat FloatFormat + // Defines whether to print stacktrace on failure and in what format. + // Default is StacktraceModeDisabled. + StacktraceMode StacktraceMode + + // Colorization mode. + // Default is ColorModeAuto. + ColorMode ColorMode + // Wrap text to keep lines below given width. // Use zero for default width, and negative value to disable wrapping. LineWidth int @@ -91,6 +118,23 @@ func (f *DefaultFormatter) FormatFailure( } } +// DigitSeparator defines the separator used to format integers and floats. +type DigitSeparator int + +const ( + // Separate using underscore + DigitSeparatorUnderscore DigitSeparator = iota + + // Separate using comma + DigitSeparatorComma + + // Separate using apostrophe + DigitSeparatorApostrophe + + // Do not separate + DigitSeparatorNone +) + // FloatFormat defines the format in which all floats are printed. type FloatFormat int @@ -112,6 +156,43 @@ const ( FloatFormatScientific ) +// StacktraceMode defines the format of stacktrace. +type StacktraceMode int + +const ( + // Don't print stacktrace. + StacktraceModeDisabled StacktraceMode = iota + + // Standard, verbose format. + StacktraceModeStandard + + // Compact format. + StacktraceModeCompact +) + +// ColorMode defines how the text color is enabled. +type ColorMode int + +const ( + // Automatically enable colors if ALL of the following is true: + // - stdout is a tty / console + // - AssertionHandler is known to output to testing.T + // - testing.Verbose() is true + // + // Colors are forcibly enabled if FORCE_COLOR environment variable + // is set to a positive integer. + // + // Colors are forcibly disabled if TERM is "dumb" or NO_COLOR + // environment variable is set to non-empty string. + ColorModeAuto ColorMode = iota + + // Unconditionally enable colors. + ColorModeAlways + + // Unconditionally disable colors. + ColorModeNever +) + // FormatData defines data passed to template engine when DefaultFormatter // formats assertion. You can use these fields in your custom templates. type FormatData struct { @@ -142,7 +223,17 @@ type FormatData struct { HaveDiff bool Diff string - LineWidth int + HaveRequest bool + Request string + + HaveResponse bool + Response string + + HaveStacktrace bool + Stacktrace []string + + EnableColors bool + LineWidth int } const ( @@ -188,7 +279,7 @@ func (f *DefaultFormatter) buildFormatData( ) *FormatData { data := FormatData{} - f.fillDescription(&data, ctx) + f.fillGeneral(&data, ctx) if failure != nil { data.AssertType = failure.Type.String() @@ -213,12 +304,16 @@ func (f *DefaultFormatter) buildFormatData( if failure.Delta != nil { f.fillDelta(&data, ctx, failure) } + + f.fillRequest(&data, ctx, failure) + f.fillResponse(&data, ctx, failure) + f.fillStacktrace(&data, ctx, failure) } return &data } -func (f *DefaultFormatter) fillDescription( +func (f *DefaultFormatter) fillGeneral( data *FormatData, ctx *AssertionContext, ) { if !f.DisableNames { @@ -234,6 +329,22 @@ func (f *DefaultFormatter) fillDescription( } } + switch f.ColorMode { + case ColorModeAuto: + switch colorMode() { + case colorsUnsupported: + data.EnableColors = false + case colorsForced: + data.EnableColors = true + case colorsSupported: + data.EnableColors = ctx.TestingTB && flag.Parsed() && testing.Verbose() + } + case ColorModeAlways: + data.EnableColors = true + case ColorModeNever: + data.EnableColors = false + } + if f.LineWidth != 0 { data.LineWidth = f.LineWidth } else { @@ -244,6 +355,8 @@ func (f *DefaultFormatter) fillDescription( func (f *DefaultFormatter) fillErrors( data *FormatData, ctx *AssertionContext, failure *AssertionFailure, ) { + data.Errors = []string{} + for _, err := range failure.Errors { if refIsNil(err) { continue @@ -426,13 +539,80 @@ func (f *DefaultFormatter) fillDelta( data.Delta = f.formatValue(failure.Delta.Value) } +func (f *DefaultFormatter) fillRequest( + data *FormatData, ctx *AssertionContext, failure *AssertionFailure, +) { + if !f.DisableRequests && ctx.Request != nil && ctx.Request.httpReq != nil { + dump, err := httputil.DumpRequest(ctx.Request.httpReq, false) + if err != nil { + return + } + + data.HaveRequest = true + data.Request = string(dump) + } +} + +func (f *DefaultFormatter) fillResponse( + data *FormatData, ctx *AssertionContext, failure *AssertionFailure, +) { + if !f.DisableResponses && ctx.Response != nil && ctx.Response.httpResp != nil { + dump, err := httputil.DumpResponse(ctx.Response.httpResp, false) + if err != nil { + return + } + + text := strings.Replace(string(dump), "\r\n", "\n", -1) + lines := strings.SplitN(text, "\n", 2) + + data.HaveResponse = true + data.Response = fmt.Sprintf("%s %s\n%s", lines[0], ctx.Response.rtt, lines[1]) + } +} + +func (f *DefaultFormatter) fillStacktrace( + data *FormatData, ctx *AssertionContext, failure *AssertionFailure, +) { + data.Stacktrace = []string{} + + switch f.StacktraceMode { + case StacktraceModeDisabled: + break + + case StacktraceModeStandard: + for _, entry := range failure.Stacktrace { + data.HaveStacktrace = true + data.Stacktrace = append(data.Stacktrace, + fmt.Sprintf("%s()\n\t%s:%d +0x%x", + entry.Func.Name(), entry.File, entry.Line, entry.FuncOffset)) + + } + + case StacktraceModeCompact: + for _, entry := range failure.Stacktrace { + if entry.IsEntrypoint { + break + } + data.HaveStacktrace = true + data.Stacktrace = append(data.Stacktrace, + fmt.Sprintf("%s() at %s:%d (%s)", + entry.FuncName, filepath.Base(entry.File), entry.Line, entry.FuncPackage)) + + } + } +} + func (f *DefaultFormatter) formatValue(value interface{}) string { if flt := extractFloat32(value); flt != nil { - return f.formatFloatValue(*flt, 32) + return f.reformatNumber(f.formatFloatValue(*flt, 32)) } if flt := extractFloat64(value); flt != nil { - return f.formatFloatValue(*flt, 64) + return f.reformatNumber(f.formatFloatValue(*flt, 64)) + } + + if refIsNum(value) { + return f.reformatNumber(fmt.Sprintf("%v", value)) } if !refIsNil(value) && !refIsHTTP(value) { @@ -561,6 +741,91 @@ func (f *DefaultFormatter) formatDiff(expected, actual interface{}) (string, boo return diffText, true } +func (f *DefaultFormatter) reformatNumber(numStr string) string { + signPart, intPart, fracPart, expPart := f.decomposeNumber(numStr) + if intPart == "" { + return numStr + } + + var sb strings.Builder + + sb.WriteString(signPart) + sb.WriteString(f.applySeparator(intPart, -1)) + + if fracPart != "" { + sb.WriteString(".") + sb.WriteString(f.applySeparator(fracPart, +1)) + } + + if expPart != "" { + sb.WriteString("e") + sb.WriteString(expPart) + } + + return sb.String() +} + +var ( + decomposeRegexp = regexp.MustCompile(`^([+-])?(\d+)([.](\d+))?([eE]([+-]?\d+))?$`) +) + +func (f *DefaultFormatter) decomposeNumber(numStr string) ( + signPart, intPart, fracPart, expPart string, +) { + parts := decomposeRegexp.FindStringSubmatch(numStr) + + if len(parts) > 1 { + signPart = parts[1] + } + if len(parts) > 2 { + intPart = parts[2] + } + if len(parts) > 4 { + fracPart = parts[4] + } + if len(parts) > 6 { + expPart = parts[6] + } + + return +} + +func (f *DefaultFormatter) applySeparator(numStr string, dir int) string { + var separator string + switch f.DigitSeparator { + case DigitSeparatorUnderscore: + separator = "_" + break + case DigitSeparatorApostrophe: + separator = "'" + break + case DigitSeparatorComma: + separator = "," + break + case DigitSeparatorNone: + default: + return numStr + } + + var sb strings.Builder + + cnt := 0 + if dir < 0 { + cnt = len(numStr) + } + + for i := 0; i != len(numStr); i++ { + sb.WriteByte(numStr[i]) + + cnt += dir + if cnt%3 == 0 && i != len(numStr)-1 { + sb.WriteString(separator) + } + } + + return sb.String() +} + func extractString(value interface{}) *string { switch s := value.(type) { case string: @@ -611,16 +876,72 @@ func extractList(value interface{}) *AssertionList { } } +var ( + colorsSupportedOnce sync.Once + colorsSupportedMode int +) + +const ( + colorsUnsupported = iota + colorsSupported + colorsForced +) + +func colorMode() int { + colorsSupportedOnce.Do(func() { + if s := os.Getenv("FORCE_COLOR"); len(s) != 0 { + if n, err := strconv.Atoi(s); err == nil && n > 0 { + colorsSupportedMode = colorsForced + return + } + } + + if (isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())) && + len(os.Getenv("NO_COLOR")) == 0 && + !strings.HasPrefix(os.Getenv("TERM"), "dumb") { + colorsSupportedMode = colorsSupported + return + } + + colorsSupportedMode = colorsUnsupported + return + }) + + return colorsSupportedMode +} + const ( defaultIndent = " " defaultLineWidth = 60 ) +var defaultColors = map[string]color.Attribute{ + // regular + "Black": color.FgBlack, + "Red": color.FgRed, + "Green": color.FgGreen, + "Yellow": color.FgYellow, + "Magenta": color.FgMagenta, + "Cyan": color.FgCyan, + "White": color.FgWhite, + // bright + "HiBlack": color.FgHiBlack, + "HiRed": color.FgHiRed, + "HiGreen": color.FgHiGreen, + "HiYellow": color.FgHiYellow, + "HiMagenta": color.FgHiMagenta, + "HiCyan": color.FgHiCyan, + "HiWhite": color.FgHiWhite, +} + var defaultTemplateFuncs = template.FuncMap{ - "indent": func(s string) string { + "trim": func(input string) string { + return strings.TrimSpace(input) + }, + "indent": func(input string) string { var sb strings.Builder - for _, s := range strings.Split(s, "\n") { + for _, s := range strings.Split(input, "\n") { if sb.Len() != 0 { sb.WriteString("\n") } @@ -630,17 +951,20 @@ var defaultTemplateFuncs = template.FuncMap{ return sb.String() }, - "wrap": func(s string, width int) string { - s = strings.TrimSpace(s) - if width < 0 { - return s + "wrap": func(width int, input string) string { + input = strings.TrimSpace(input) + + width -= len(defaultIndent) + if width <= 0 { + return input } - return wordwrap.WrapString(s, uint(width)) + return wordwrap.WrapString(input, uint(width)) }, - "join": func(strs []string, width int) string { - if width < 0 { - return strings.Join(strs, ".") + "join": func(width int, tokenList []string) string { + width -= len(defaultIndent) + if width <= 0 { + return strings.Join(tokenList, ".") } var sb strings.Builder @@ -653,49 +977,112 @@ var defaultTemplateFuncs = template.FuncMap{ lineLen += len(s) } - for n, s := range strs { - if lineLen > width { + for n, token := range tokenList { + if lineLen+len(token)+1 > width { write("\n") lineLen = 0 - lineNum++ + if lineNum < 2 { + lineNum++ + } } if lineLen == 0 { for l := 0; l < lineNum; l++ { write(defaultIndent) } } - write(s) - if n != len(strs)-1 { + write(token) + if n != len(tokenList)-1 { write(".") } } + return sb.String() + }, + "color": func(enable bool, colorName, input string) string { + if !enable { + return input + } + colorAttr := color.Reset + if ca, ok := defaultColors[colorName]; ok { + colorAttr = ca + } + return color.New(colorAttr).Sprint(input) + }, + "colordiff": func(enable bool, input string) string { + if !enable { + return input + } + + prefixColor := []struct { + prefix string + color color.Attribute + }{ + {"---", color.FgWhite}, + {"+++", color.FgWhite}, + {"-", color.FgRed}, + {"+", color.FgGreen}, + } + + lineColor := func(s string) color.Attribute { + for _, pc := range prefixColor { + if strings.HasPrefix(s, pc.prefix) { + return pc.color + } + } + + return color.Reset + } + + var sb strings.Builder + for _, line := range strings.Split(input, "\n") { + if sb.Len() != 0 { + sb.WriteString("\n") + } + + sb.WriteString(color.New(lineColor(line)).Sprint(line)) + } + return sb.String() }, } -var defaultSuccessTemplate = `[OK] {{ join .AssertPath .LineWidth }}` +var defaultSuccessTemplate = `[OK] {{ join .LineWidth .AssertPath }}` var defaultFailureTemplate = ` {{- range $n, $err := .Errors }} {{ if eq $n 0 -}} -{{ wrap $err $.LineWidth }} +{{ $err | wrap $.LineWidth | color $.EnableColors "Red" }} {{- else -}} -{{ wrap $err $.LineWidth | indent }} +{{ $err | wrap $.LineWidth | indent | color $.EnableColors "Red" }} {{- end -}} {{- end -}} {{- if .TestName }} -test name: {{ .TestName }} +test name: {{ .TestName | color $.EnableColors "Cyan" }} {{- end -}} {{- if .RequestName }} -request name: {{ .RequestName }} +request name: {{ .RequestName | color $.EnableColors "Cyan" }} +{{- end -}} +{{- if .HaveRequest }} + +request: {{ .Request | indent | trim | color $.EnableColors "HiMagenta" }} +{{- end -}} +{{- if .HaveResponse }} + +response: {{ .Response | indent | trim | color $.EnableColors "HiMagenta" }} +{{- end -}} +{{- if .HaveStacktrace }} + +trace: +{{- range $n, $call := .Stacktrace }} +{{ $call | indent }} +{{- end -}} {{- end -}} {{- if .AssertPath }} assertion: -{{ join .AssertPath .LineWidth | indent }} +{{ join .LineWidth .AssertPath | indent | color .EnableColors "Yellow" }} {{- end -}} {{- if .HaveExpected }} @@ -704,27 +1091,27 @@ assertion: {{- else }}expected {{- end }} {{ .ExpectedKind }}: {{- range $n, $exp := .Expected }} -{{ $exp | indent }} +{{ $exp | indent | color $.EnableColors "HiMagenta" }} {{- end -}} {{- end -}} {{- if .HaveActual }} actual value: -{{ .Actual | indent }} +{{ .Actual | indent | color .EnableColors "HiMagenta" }} {{- end -}} {{- if .HaveReference }} reference value: -{{ .Reference | indent }} +{{ .Reference | indent | color .EnableColors "HiMagenta" }} {{- end -}} {{- if .HaveDelta }} allowed delta: -{{ .Delta | indent }} +{{ .Delta | indent | color .EnableColors "HiMagenta" }} {{- end -}} {{- if .HaveDiff }} diff: -{{ .Diff | indent }} +{{ .Diff | colordiff .EnableColors | indent }} {{- end -}} ` diff --git a/go.mod b/go.mod index e45335c10..2eae4f5fb 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,23 @@ module github.com/iris-contrib/httpexpect/v2 -go 1.19 +go 1.21 require ( github.com/ajg/form v1.5.1 + github.com/fatih/color v1.15.0 github.com/fatih/structs v1.1.0 + github.com/gobwas/glob v0.2.3 github.com/google/go-querystring v1.1.0 github.com/gorilla/websocket v1.5.0 github.com/imkira/go-interpol v1.1.0 + github.com/mattn/go-isatty v0.0.19 github.com/mitchellh/go-wordwrap v1.0.1 github.com/sanity-io/litter v1.5.5 - github.com/stretchr/testify v1.8.1 + github.com/stretchr/testify v1.8.4 github.com/xeipuuv/gojsonschema v1.2.0 github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 github.com/yudai/gojsondiff v1.0.0 - golang.org/x/net v0.7.0 + golang.org/x/net v0.14.0 moul.io/http2curl/v2 v2.3.0 ) @@ -23,12 +26,13 @@ require ( github.com/google/go-cmp v0.5.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/onsi/ginkgo v1.16.5 // indirect - github.com/onsi/gomega v1.27.1 // indirect + github.com/onsi/gomega v1.27.10 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sergi/go-diff v1.0.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect github.com/yudai/pp v2.0.1+incompatible // indirect + golang.org/x/sys v0.11.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3bfcf61ec..2177712ac 100644 --- a/go.sum +++ b/go.sum @@ -4,12 +4,16 @@ github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -32,8 +36,9 @@ github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -45,8 +50,8 @@ github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.27.1 h1:rfztXRbg6nv/5f+Raen9RcGoSecHIFgBBLQK3Wdj754= -github.com/onsi/gomega v1.27.1/go.mod h1:aHX5xOykVYzWOV4WqQy0sy8BQptgukenXpCXfadcIAw= +github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28= github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -56,16 +61,12 @@ github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= @@ -92,8 +93,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -107,10 +108,13 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= @@ -133,7 +137,6 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= moul.io/http2curl/v2 v2.3.0 h1:9r3JfDzWPcbIklMOs2TnIFzDYvfAZvjeavG6EzP7jYs= diff --git a/number.go b/number.go index 32150bb20..389a8585d 100644 --- a/number.go +++ b/number.go @@ -2,7 +2,9 @@ package httpexpect import ( "errors" + "fmt" "math" + "math/big" ) // Number provides methods to inspect attached float64 value @@ -618,3 +620,474 @@ func (n *Number) Le(value interface{}) *Number { return n } + +// IsInt succeeds if number is a signed integer of the specified bit width +// as an optional argument. +// +// Bits argument defines maximum allowed bitness for the given number. +// If bits is omitted, boundary check is omitted too. +// +// Example: +// +// number := NewNumber(t, 1000000) +// number.IsInt() // success +// number.IsInt(32) // success +// number.IsInt(16) // failure +// +// number := NewNumber(t, -1000000) +// number.IsInt() // success +// number.IsInt(32) // success +// number.IsInt(16) // failure +// +// number := NewNumber(t, 0.5) +// number.IsInt() // failure +func (n *Number) IsInt(bits ...int) *Number { + opChain := n.chain.enter("IsInt()") + defer opChain.leave() + + if opChain.failed() { + return n + } + + if len(bits) > 1 { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected multiple bits arguments"), + }, + }) + return n + } + + if len(bits) == 1 && bits[0] <= 0 { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected non-positive bits argument"), + }, + }) + return n + } + + if math.IsNaN(n.value) { + opChain.fail(AssertionFailure{ + Type: AssertValid, + Actual: &AssertionValue{n.value}, + Errors: []error{ + errors.New("expected: number is signed integer"), + }, + }) + return n + } + + inum, acc := big.NewFloat(n.value).Int(nil) + if !(acc == big.Exact) { + opChain.fail(AssertionFailure{ + Type: AssertValid, + Actual: &AssertionValue{n.value}, + Errors: []error{ + errors.New("expected: number is signed integer"), + }, + }) + return n + } + + if len(bits) > 0 { + bitSize := bits[0] + + imax := new(big.Int) + imax.Lsh(big.NewInt(1), uint(bitSize-1)) + imax.Sub(imax, big.NewInt(1)) + imin := new(big.Int) + imin.Neg(imax) + imin.Sub(imin, big.NewInt(1)) + if inum.Cmp(imin) < 0 || inum.Cmp(imax) > 0 { + opChain.fail(AssertionFailure{ + Type: AssertInRange, + Actual: &AssertionValue{n.value}, + Expected: &AssertionValue{AssertionRange{ + Min: intBoundary{imin, -1, bitSize - 1}, + Max: intBoundary{imax, +1, bitSize - 1}, + }}, + Errors: []error{ + fmt.Errorf("expected: number is %d-bit signed integer", bitSize), + }, + }) + return n + } + } + + return n +} + +// NotInt succeeds if number is not a signed integer of the specified bit +// width as an optional argument. +// +// Bits argument defines maximum allowed bitness for the given number. +// If bits is omitted, boundary check is omitted too. +// +// Example: +// +// number := NewNumber(t, 1000000) +// number.NotInt() // failure +// number.NotInt(32) // failure +// number.NotInt(16) // success +// +// number := NewNumber(t, -1000000) +// number.NotInt() // failure +// number.NotInt(32) // failure +// number.NotInt(16) // success +// +// number := NewNumber(t, 0.5) +// number.NotInt() // success +func (n *Number) NotInt(bits ...int) *Number { + opChain := n.chain.enter("NotInt()") + defer opChain.leave() + + if opChain.failed() { + return n + } + + if len(bits) > 1 { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected multiple bits arguments"), + }, + }) + return n + } + + if len(bits) == 1 && bits[0] <= 0 { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected non-positive bits argument"), + }, + }) + return n + } + + if !math.IsNaN(n.value) { + inum, acc := big.NewFloat(n.value).Int(nil) + if acc == big.Exact { + if len(bits) == 0 { + opChain.fail(AssertionFailure{ + Type: AssertValid, + Actual: &AssertionValue{n.value}, + Errors: []error{ + errors.New("expected: number is not signed integer"), + }, + }) + return n + } + + bitSize := bits[0] + imax := new(big.Int) + imax.Lsh(big.NewInt(1), uint(bitSize-1)) + imax.Sub(imax, big.NewInt(1)) + imin := new(big.Int) + imin.Neg(imax) + imin.Sub(imin, big.NewInt(1)) + if !(inum.Cmp(imin) < 0 || inum.Cmp(imax) > 0) { + opChain.fail(AssertionFailure{ + Type: AssertNotInRange, + Actual: &AssertionValue{n.value}, + Expected: &AssertionValue{AssertionRange{ + Min: intBoundary{imin, -1, bitSize - 1}, + Max: intBoundary{imax, +1, bitSize - 1}, + }}, + Errors: []error{ + fmt.Errorf( + "expected: number doesn't fit %d-bit signed integer", + bitSize), + }, + }) + return n + } + } + } + + return n +} + +// IsUint succeeds if number is an unsigned integer of the specified bit +// width as an optional argument. +// +// Bits argument defines maximum allowed bitness for the given number. +// If bits is omitted, boundary check is omitted too. +// +// Example: +// +// number := NewNumber(t, 1000000) +// number.IsUint() // success +// number.IsUint(32) // success +// number.IsUint(16) // failure +// +// number := NewNumber(t, -1000000) +// number.IsUint() // failure +// number.IsUint(32) // failure +// number.IsUint(16) // failure +// +// number := NewNumber(t, 0.5) +// number.IsUint() // failure +func (n *Number) IsUint(bits ...int) *Number { + opChain := n.chain.enter("IsUint()") + defer opChain.leave() + + if opChain.failed() { + return n + } + + if len(bits) > 1 { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected multiple bits arguments"), + }, + }) + return n + } + + if len(bits) == 1 && bits[0] <= 0 { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected non-positive bits argument"), + }, + }) + return n + } + + if math.IsNaN(n.value) { + opChain.fail(AssertionFailure{ + Type: AssertValid, + Actual: &AssertionValue{n.value}, + Errors: []error{ + errors.New("expected: number is unsigned integer"), + }, + }) + return n + } + + inum, acc := big.NewFloat(n.value).Int(nil) + if !(acc == big.Exact) { + opChain.fail(AssertionFailure{ + Type: AssertValid, + Actual: &AssertionValue{n.value}, + Errors: []error{ + errors.New("expected: number is unsigned integer"), + }, + }) + return n + } + + imin := big.NewInt(0) + if inum.Cmp(imin) < 0 { + opChain.fail(AssertionFailure{ + Type: AssertValid, + Actual: &AssertionValue{n.value}, + Errors: []error{ + errors.New("expected: number is unsigned integer"), + }, + }) + return n + } + + if len(bits) > 0 { + bitSize := bits[0] + imax := new(big.Int) + imax.Lsh(big.NewInt(1), uint(bitSize)) + imax.Sub(imax, big.NewInt(1)) + if inum.Cmp(imax) > 0 { + opChain.fail(AssertionFailure{ + Type: AssertInRange, + Actual: &AssertionValue{n.value}, + Expected: &AssertionValue{AssertionRange{ + Min: intBoundary{imin, 0, 0}, + Max: intBoundary{imax, +1, bitSize}, + }}, + Errors: []error{ + fmt.Errorf("expected: number fits %d-bit unsigned integer", bitSize), + }, + }) + return n + } + } + + return n +} + +// NotUint succeeds if number is not an unsigned integer of the specified bit +// width as an optional argument. +// +// Bits argument defines maximum allowed bitness for the given number. +// If bits is omitted, boundary check is omitted too. +// +// Example: +// +// number := NewNumber(t, 1000000) +// number.NotUint() // failure +// number.NotUint(32) // failure +// number.NotUint(16) // success +// +// number := NewNumber(t, -1000000) +// number.NotUint() // success +// number.NotUint(32) // success +// number.NotUint(16) // success +// +// number := NewNumber(t, 0.5) +// number.NotUint() // success +func (n *Number) NotUint(bits ...int) *Number { + opChain := n.chain.enter("NotUint()") + defer opChain.leave() + + if opChain.failed() { + return n + } + + if len(bits) > 1 { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected multiple bits arguments"), + }, + }) + return n + } + + if len(bits) == 1 && bits[0] <= 0 { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected non-positive bits argument"), + }, + }) + return n + } + + if !math.IsNaN(n.value) { + inum, acc := big.NewFloat(n.value).Int(nil) + if acc == big.Exact { + imin := big.NewInt(0) + if inum.Cmp(imin) >= 0 { + if len(bits) == 0 { + opChain.fail(AssertionFailure{ + Type: AssertValid, + Actual: &AssertionValue{n.value}, + Errors: []error{ + errors.New("expected: number is not unsigned integer"), + }, + }) + return n + } + + bitSize := bits[0] + imax := new(big.Int) + imax.Lsh(big.NewInt(1), uint(bitSize)) + imax.Sub(imax, big.NewInt(1)) + if inum.Cmp(imax) <= 0 { + opChain.fail(AssertionFailure{ + Type: AssertNotInRange, + Actual: &AssertionValue{n.value}, + Expected: &AssertionValue{AssertionRange{ + Min: intBoundary{imin, 0, 0}, + Max: intBoundary{imax, +1, bitSize}, + }}, + Errors: []error{ + fmt.Errorf( + "expected: number doesn't fit %d-bit unsigned integer", + bitSize), + }, + }) + return n + } + } + } + } + + return n +} + +// IsFinite succeeds if number is neither ±Inf nor NaN. +// +// Example: +// +// number := NewNumber(t, 1234.5) +// number.IsFinite() // success +// +// number := NewNumber(t, math.NaN()) +// number.IsFinite() // failure +// +// number := NewNumber(t, math.Inf(+1)) +// number.IsFinite() // failure +func (n *Number) IsFinite() *Number { + opChain := n.chain.enter("IsFinite()") + defer opChain.leave() + + if opChain.failed() { + return n + } + + if math.IsInf(n.value, 0) || math.IsNaN(n.value) { + opChain.fail(AssertionFailure{ + Type: AssertValid, + Actual: &AssertionValue{n.value}, + Errors: []error{ + errors.New("expected: number is neither ±Inf nor NaN"), + }, + }) + return n + } + + return n +} + +// NotFinite succeeds if number is either ±Inf or NaN. +// +// Example: +// +// number := NewNumber(t, 1234.5) +// number.NotFinite() // failure +// +// number := NewNumber(t, math.NaN()) +// number.NotFinite() // success +// +// number := NewNumber(t, math.Inf(+1)) +// number.NotFinite() // success +func (n *Number) NotFinite() *Number { + opChain := n.chain.enter("NotFinite()") + defer opChain.leave() + + if opChain.failed() { + return n + } + + if !(math.IsInf(n.value, 0) || math.IsNaN(n.value)) { + opChain.fail(AssertionFailure{ + Type: AssertValid, + Actual: &AssertionValue{n.value}, + Errors: []error{ + errors.New("expected: number is either ±Inf or NaN"), + }, + }) + return n + } + + return n +} + +type intBoundary struct { + val *big.Int + sign int + bits int +} + +func (b intBoundary) String() string { + if b.sign > 0 { + return fmt.Sprintf("+2^%d-1 (+%s)", b.bits, b.val) + } else if b.sign < 0 { + return fmt.Sprintf("-2^%d (%s)", b.bits, b.val) + } + return fmt.Sprintf("%s", b.val) +} diff --git a/object.go b/object.go index 4b662fb13..ec827538d 100644 --- a/object.go +++ b/object.go @@ -219,6 +219,121 @@ func (o *Object) Value(key string) *Value { return newValue(opChain, value) } +// HasValue succeeds if object's value for given key is equal to given value. +// Before comparison, both values are converted to canonical form. +// +// value should be map[string]interface{} or struct. +// +// Example: +// +// object := NewObject(t, map[string]interface{}{"foo": 123}) +// object.HasValue("foo", 123) +func (o *Object) HasValue(key string, value interface{}) *Object { + opChain := o.chain.enter("HasValue(%q)", key) + defer opChain.leave() + + if opChain.failed() { + return o + } + + if !containsKey(opChain, o.value, key) { + opChain.fail(AssertionFailure{ + Type: AssertContainsKey, + Actual: &AssertionValue{o.value}, + Expected: &AssertionValue{key}, + Errors: []error{ + errors.New("expected: map contains key"), + }, + }) + return o + } + + expected, ok := canonValue(opChain, value) + if !ok { + return o + } + + if !reflect.DeepEqual(expected, o.value[key]) { + opChain.fail(AssertionFailure{ + Type: AssertEqual, + Actual: &AssertionValue{o.value[key]}, + Expected: &AssertionValue{value}, + Errors: []error{ + fmt.Errorf( + "expected: map value for key %q is equal to given value", + key), + }, + }) + return o + } + + return o +} + +// NotHasValue succeeds if object's value for given key is not equal to given +// value. Before comparison, both values are converted to canonical form. +// +// value should be map[string]interface{} or struct. +// +// If object doesn't contain any value for given key, failure is reported. +// +// Example: +// +// object := NewObject(t, map[string]interface{}{"foo": 123}) +// object.NotHasValue("foo", "bad value") // success +// object.NotHasValue("bar", "bad value") // failure! (key is missing) +func (o *Object) NotHasValue(key string, value interface{}) *Object { + opChain := o.chain.enter("NotHasValue(%q)", key) + defer opChain.leave() + + if opChain.failed() { + return o + } + + if !containsKey(opChain, o.value, key) { + opChain.fail(AssertionFailure{ + Type: AssertContainsKey, + Actual: &AssertionValue{o.value}, + Expected: &AssertionValue{key}, + Errors: []error{ + errors.New("expected: map contains key"), + }, + }) + return o + } + + expected, ok := canonValue(opChain, value) + if !ok { + return o + } + + if reflect.DeepEqual(expected, o.value[key]) { + opChain.fail(AssertionFailure{ + Type: AssertNotEqual, + Actual: &AssertionValue{o.value[key]}, + Expected: &AssertionValue{value}, + Errors: []error{ + fmt.Errorf( + "expected: map value for key %q is non-equal to given value", + key), + }, + }) + return o + } + + return o +} + +// Deprecated: use HasValue instead. +func (o *Object) ValueEqual(key string, value interface{}) *Object { + return o.HasValue(key, value) +} + +// Deprecated: use NotHasValue instead. +func (o *Object) ValueNotEqual(key string, value interface{}) *Object { + return o.NotHasValue(key, value) +} + // Iter returns a new map of Values attached to object elements. // // Example: @@ -227,7 +342,7 @@ func (o *Object) Value(key string) *Value { // object := NewObject(t, numbers) // // for key, value := range object.Iter() { -// value.Number().IsEqual(numbers[key]) +// value.Number().IsEqual(numbers[key]) // } func (o *Object) Iter() map[string]Value { opChain := o.chain.enter("Iter()") @@ -980,28 +1095,28 @@ func (o *Object) NotContainsValue(value interface{}) *Object { // Example: // // object := NewObject(t, map[string]interface{}{ -// "foo": 123, -// "bar": []interface{}{"x", "y"}, -// "bar": map[string]interface{}{ -// "a": true, -// "b": false, -// }, +// "foo": 123, +// "bar": []interface{}{"x", "y"}, +// "bar": map[string]interface{}{ +// "a": true, +// "b": false, +// }, // }) // // object.ContainsSubset(map[string]interface{}{ // success -// "foo": 123, -// "bar": map[string]interface{}{ -// "a": true, -// }, +// "foo": 123, +// "bar": map[string]interface{}{ +// "a": true, +// }, // }) // // object.ContainsSubset(map[string]interface{}{ // failure -// "foo": 123, -// "qux": 456, +// "foo": 123, +// "qux": 456, // }) // // object.ContainsSubset(map[string]interface{}{ // failure, slices should match exactly -// "bar": []interface{}{"x"}, +// "bar": []interface{}{"x"}, // }) func (o *Object) ContainsSubset(value interface{}) *Object { opChain := o.chain.enter("ContainsSubset()") @@ -1066,121 +1181,6 @@ func (o *Object) NotContainsMap(value interface{}) *Object { return o.NotContainsSubset(value) } -// IsValueEqual succeeds if object's value for given key is equal to given value. -// Before comparison, both values are converted to canonical form. -// -// value should be map[string]interface{} or struct. -// -// Example: -// -// object := NewObject(t, map[string]interface{}{"foo": 123}) -// object.IsValueEqual("foo", 123) -func (o *Object) IsValueEqual(key string, value interface{}) *Object { - opChain := o.chain.enter("IsValueEqual(%q)", key) - defer opChain.leave() - - if opChain.failed() { - return o - } - - if !containsKey(opChain, o.value, key) { - opChain.fail(AssertionFailure{ - Type: AssertContainsKey, - Actual: &AssertionValue{o.value}, - Expected: &AssertionValue{key}, - Errors: []error{ - errors.New("expected: map contains key"), - }, - }) - return o - } - - expected, ok := canonValue(opChain, value) - if !ok { - return o - } - - if !reflect.DeepEqual(expected, o.value[key]) { - opChain.fail(AssertionFailure{ - Type: AssertEqual, - Actual: &AssertionValue{o.value[key]}, - Expected: &AssertionValue{value}, - Errors: []error{ - fmt.Errorf( - "expected: map value for key %q is equal to given value", - key), - }, - }) - return o - } - - return o -} - -// NotValueEqual succeeds if object's value for given key is not equal to given -// value. Before comparison, both values are converted to canonical form. -// -// value should be map[string]interface{} or struct. -// -// If object doesn't contain any value for given key, failure is reported. -// -// Example: -// -// object := NewObject(t, map[string]interface{}{"foo": 123}) -// object.NotValueEqual("foo", "bad value") // success -// object.NotValueEqual("bar", "bad value") // failure! (key is missing) -func (o *Object) NotValueEqual(key string, value interface{}) *Object { - opChain := o.chain.enter("ValueNotEqual(%q)", key) - defer opChain.leave() - - if opChain.failed() { - return o - } - - if !containsKey(opChain, o.value, key) { - opChain.fail(AssertionFailure{ - Type: AssertContainsKey, - Actual: &AssertionValue{o.value}, - Expected: &AssertionValue{key}, - Errors: []error{ - errors.New("expected: map contains key"), - }, - }) - return o - } - - expected, ok := canonValue(opChain, value) - if !ok { - return o - } - - if reflect.DeepEqual(expected, o.value[key]) { - opChain.fail(AssertionFailure{ - Type: AssertNotEqual, - Actual: &AssertionValue{o.value[key]}, - Expected: &AssertionValue{value}, - Errors: []error{ - fmt.Errorf( - "expected: map value for key %q is non-equal to given value", - key), - }, - }) - return o - } - - return o -} - -// Deprecated: use IsValueEqual instead. -func (o *Object) ValueEqual(key string, value interface{}) *Object { - return o.IsValueEqual(key, value) -} - -// Deprecated: use NotValueEqual instead. -func (o *Object) ValueNotEqual(key string, value interface{}) *Object { - return o.NotValueEqual(key, value) -} - type kv struct { key string val interface{} diff --git a/reporter.go b/reporter.go index 954739b77..9c959b3db 100644 --- a/reporter.go +++ b/reporter.go @@ -1,6 +1,7 @@ package httpexpect import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -20,7 +21,7 @@ func NewAssertReporter(t assert.TestingT) *AssertReporter { // Errorf implements Reporter.Errorf. func (r *AssertReporter) Errorf(message string, args ...interface{}) { - r.backend.Fail(message, args...) + r.backend.Fail(fmt.Sprintf(message, args...)) } // RequireReporter implements Reporter interface using `testify/require' @@ -36,7 +37,7 @@ func NewRequireReporter(t require.TestingT) *RequireReporter { // Errorf implements Reporter.Errorf. func (r *RequireReporter) Errorf(message string, args ...interface{}) { - r.backend.FailNow(message, args...) + r.backend.FailNow(fmt.Sprintf(message, args...)) } // FatalReporter is a struct that implements the Reporter interface @@ -52,5 +53,22 @@ func NewFatalReporter(t testing.TB) *FatalReporter { // Errorf implements Reporter.Errorf. func (r *FatalReporter) Errorf(message string, args ...interface{}) { - r.backend.Fatalf(message, args...) + r.backend.Fatalf(fmt.Sprintf(message, args...)) +} + +// PanicReporter is a struct that implements the Reporter interface +// and panics when a test fails. +// Useful for multithreaded tests when you want to report fatal +// failures from goroutines other than the main goroutine, because +// the main goroutine is forbidden to call t.Fatal. +type PanicReporter struct{} + +// NewPanicReporter returns a new PanicReporter object. +func NewPanicReporter() *PanicReporter { + return &PanicReporter{} +} + +// Errorf implements Reporter.Errorf +func (r *PanicReporter) Errorf(message string, args ...interface{}) { + panic(fmt.Sprintf(message, args...)) } diff --git a/request.go b/request.go index 98ccbeff6..4994bb47a 100644 --- a/request.go +++ b/request.go @@ -16,6 +16,7 @@ import ( "reflect" "sort" "strings" + "sync" "time" "github.com/ajg/form" @@ -28,7 +29,8 @@ import ( // Request provides methods to incrementally build http.Request object, // send it, and receive response. type Request struct { - noCopy noCopy + mu sync.Mutex + config Config chain *chain @@ -47,9 +49,10 @@ type Request struct { path string query url.Values - form url.Values - formbuf *bytes.Buffer - multipart *multipart.Writer + form url.Values + formbuf *bytes.Buffer + multipart *multipart.Writer + multipartFn func(w io.Writer) *multipart.Writer bodySetter string typeSetter string @@ -58,8 +61,8 @@ type Request struct { wsUpgrade bool - transforms []func(*http.Request) - matchers []func(*Response) + transformers []func(*http.Request) + matchers []func(*Response) } // Deprecated: use NewRequestC instead. @@ -81,15 +84,15 @@ func NewRequest(config Config, method, path string, pathargs ...interface{}) *Re // // For example: // -// req := NewRequestC(config, "POST", "/repos/{user}/{repo}", "gavv", "httpexpect") -// // path will be "/repos/gavv/httpexpect" +// req := NewRequestC(config, "POST", "/repos/{user}/{repo}", "iris-contrib", "httpexpect") +// // path will be "/repos/iris-contrib/httpexpect" // // Or: // // req := NewRequestC(config, "POST", "/repos/{user}/{repo}") -// req.WithPath("user", "gavv") +// req.WithPath("user", "iris-contrib") // req.WithPath("repo", "httpexpect") -// // path will be "/repos/gavv/httpexpect" +// // path will be "/repos/iris-contrib/httpexpect" // // After interpolation, path is urlencoded and appended to Config.BaseURL, // separated by slash. If BaseURL ends with a slash and path (after interpolation) @@ -125,6 +128,9 @@ func newRequest( sleepFn: func(d time.Duration) <-chan time.Time { return time.After(d) }, + multipartFn: func(w io.Writer) *multipart.Writer { + return multipart.NewWriter(w) + }, } opChain := r.chain.enter("") @@ -139,39 +145,42 @@ func newRequest( } func (r *Request) initPath(opChain *chain, path string, pathargs ...interface{}) { - var n int - - path, err := interpol.WithFunc(path, func(k string, w io.Writer) error { - if n < len(pathargs) { - if pathargs[n] == nil { - opChain.fail(AssertionFailure{ - Type: AssertValid, - Actual: &AssertionValue{pathargs}, - Errors: []error{ - fmt.Errorf("unexpected nil argument at index %d", n), - }, - }) + if len(pathargs) != 0 { + var n int + + var err error + path, err = interpol.WithFunc(path, func(k string, w io.Writer) error { + if n < len(pathargs) { + if pathargs[n] == nil { + opChain.fail(AssertionFailure{ + Type: AssertValid, + Actual: &AssertionValue{pathargs}, + Errors: []error{ + fmt.Errorf("unexpected nil argument at index %d", n), + }, + }) + } else { + mustWrite(w, fmt.Sprint(pathargs[n])) + } } else { - mustWrite(w, fmt.Sprint(pathargs[n])) + mustWrite(w, "{") + mustWrite(w, k) + mustWrite(w, "}") } - } else { - mustWrite(w, "{") - mustWrite(w, k) - mustWrite(w, "}") - } - n++ - return nil - }) - - if err != nil { - opChain.fail(AssertionFailure{ - Type: AssertValid, - Actual: &AssertionValue{path}, - Errors: []error{ - errors.New("invalid interpol string"), - err, - }, + n++ + return nil }) + + if err != nil { + opChain.fail(AssertionFailure{ + Type: AssertValid, + Actual: &AssertionValue{path}, + Errors: []error{ + errors.New("invalid interpol string"), + err, + }, + }) + } } r.path = path @@ -198,6 +207,9 @@ func (r *Request) Alias(name string) *Request { opChain := r.chain.enter("Alias(%q)", name) defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + r.chain.setAlias(name) return r } @@ -214,6 +226,9 @@ func (r *Request) WithName(name string) *Request { opChain := r.chain.enter("WithName()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -227,6 +242,94 @@ func (r *Request) WithName(name string) *Request { return r } +// WithReporter sets reporter to be used for this request. +// +// The new reporter overwrites AssertionHandler. +// The new AssertionHandler is DefaultAssertionHandler with specified reporter, +// existing Config.Formatter and nil Logger. +// It will be used to report formatted fatal failure messages. +// +// Example: +// +// req := NewRequestC(config, "GET", "http://example.com/path") +// req.WithReporter(t) +func (r *Request) WithReporter(reporter Reporter) *Request { + opChain := r.chain.enter("WithReporter()") + defer opChain.leave() + + r.mu.Lock() + defer r.mu.Unlock() + + if opChain.failed() { + return r + } + + if !r.checkOrder(opChain, "WithReporter()") { + return r + } + + if reporter == nil { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected nil argument"), + }, + }) + return r + } + + handler := &DefaultAssertionHandler{ + Reporter: reporter, + Formatter: r.config.Formatter, + } + r.chain.setHandler(handler) + + return r +} + +// WithAssertionHandler sets assertion handler to be used for this request. +// +// The new handler overwrites assertion handler that will be used +// by Request and its children (Response, Body, etc.). +// It will be used to format and report test Failure or Success. +// +// Example: +// +// req := NewRequestC(config, "GET", "http://example.com/path") +// req.WithAssertionHandler(&DefaultAssertionHandler{ +// Reporter: reporter, +// Formatter: formatter, +// }) +func (r *Request) WithAssertionHandler(handler AssertionHandler) *Request { + opChain := r.chain.enter("WithAssertionHandler()") + defer opChain.leave() + + r.mu.Lock() + defer r.mu.Unlock() + + if opChain.failed() { + return r + } + + if !r.checkOrder(opChain, "WithAssertionHandler()") { + return r + } + + if handler == nil { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected nil argument"), + }, + }) + return r + } + + r.chain.setHandler(handler) + + return r +} + // WithMatcher attaches a matcher to the request. // All attached matchers are invoked in the Expect method for a newly // created Response. @@ -235,12 +338,15 @@ func (r *Request) WithName(name string) *Request { // // req := NewRequestC(config, "GET", "/path") // req.WithMatcher(func (resp *httpexpect.Response) { -// resp.Header("API-Version").NotEmpty() +// resp.Header("API-Version").NotEmpty() // }) func (r *Request) WithMatcher(matcher func(*Response)) *Request { opChain := r.chain.enter("WithMatcher()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -271,10 +377,13 @@ func (r *Request) WithMatcher(matcher func(*Response)) *Request { // // req := NewRequestC(config, "PUT", "http://example.com/path") // req.WithTransformer(func(r *http.Request) { r.Header.Add("foo", "bar") }) -func (r *Request) WithTransformer(transform func(*http.Request)) *Request { +func (r *Request) WithTransformer(transformer func(*http.Request)) *Request { opChain := r.chain.enter("WithTransformer()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -283,7 +392,7 @@ func (r *Request) WithTransformer(transform func(*http.Request)) *Request { return r } - if transform == nil { + if transformer == nil { opChain.fail(AssertionFailure{ Type: AssertUsage, Errors: []error{ @@ -293,7 +402,7 @@ func (r *Request) WithTransformer(transform func(*http.Request)) *Request { return r } - r.transforms = append(r.transforms, transform) + r.transformers = append(r.transformers, transformer) return r } @@ -308,13 +417,16 @@ func (r *Request) WithTransformer(transform func(*http.Request)) *Request { // req := NewRequestC(config, "GET", "/path") // req.WithClient(&http.Client{ // Transport: &http.Transport{ -// DisableCompression: true, +// DisableCompression: true, // }, // }) func (r *Request) WithClient(client Client) *Request { opChain := r.chain.enter("WithClient()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -352,6 +464,9 @@ func (r *Request) WithHandler(handler http.Handler) *Request { opChain := r.chain.enter("WithHandler()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -401,6 +516,9 @@ func (r *Request) WithContext(ctx context.Context) *Request { opChain := r.chain.enter("WithContext()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -443,6 +561,9 @@ func (r *Request) WithTimeout(timeout time.Duration) *Request { opChain := r.chain.enter("WithTimeout()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -512,6 +633,9 @@ func (r *Request) WithRedirectPolicy(policy RedirectPolicy) *Request { opChain := r.chain.enter("WithRedirectPolicy()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -544,6 +668,9 @@ func (r *Request) WithMaxRedirects(maxRedirects int) *Request { opChain := r.chain.enter("WithMaxRedirects()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -618,6 +745,9 @@ func (r *Request) WithRetryPolicy(policy RetryPolicy) *Request { opChain := r.chain.enter("WithRetryPolicy()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -650,6 +780,9 @@ func (r *Request) WithMaxRetries(maxRetries int) *Request { opChain := r.chain.enter("WithMaxRetries()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -690,6 +823,9 @@ func (r *Request) WithRetryDelay(minDelay, maxDelay time.Duration) *Request { opChain := r.chain.enter("WithRetryDelay()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -741,6 +877,9 @@ func (r *Request) WithWebsocketUpgrade() *Request { opChain := r.chain.enter("WithWebsocketUpgrade()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -772,6 +911,9 @@ func (r *Request) WithWebsocketDialer(dialer WebsocketDialer) *Request { opChain := r.chain.enter("WithWebsocketDialer()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -805,13 +947,16 @@ func (r *Request) WithWebsocketDialer(dialer WebsocketDialer) *Request { // Example: // // req := NewRequestC(config, "POST", "/repos/{user}/{repo}") -// req.WithPath("user", "gavv") +// req.WithPath("user", "iris-contrib") // req.WithPath("repo", "httpexpect") -// // path will be "/repos/gavv/httpexpect" +// // path will be "/repos/iris-contrib/httpexpect" func (r *Request) WithPath(key string, value interface{}) *Request { opChain := r.chain.enter("WithPath()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -820,16 +965,6 @@ func (r *Request) WithPath(key string, value interface{}) *Request { return r } - if value == nil { - opChain.fail(AssertionFailure{ - Type: AssertUsage, - Errors: []error{ - errors.New("unexpected nil argument"), - }, - }) - return r - } - r.withPath(opChain, key, value) return r @@ -850,21 +985,24 @@ func (r *Request) WithPath(key string, value interface{}) *Request { // Example: // // type MyPath struct { -// Login string `path:"user"` -// Repo string +// Login string `path:"user"` +// Repo string // } // // req := NewRequestC(config, "POST", "/repos/{user}/{repo}") -// req.WithPathObject(MyPath{"gavv", "httpexpect"}) -// // path will be "/repos/gavv/httpexpect" +// req.WithPathObject(MyPath{"iris-contrib", "httpexpect"}) +// // path will be "/repos/iris-contrib/httpexpect" // // req := NewRequestC(config, "POST", "/repos/{user}/{repo}") -// req.WithPathObject(map[string]string{"user": "gavv", "repo": "httpexpect"}) -// // path will be "/repos/gavv/httpexpect" +// req.WithPathObject(map[string]string{"user": "iris-contrib", "repo": "httpexpect"}) +// // path will be "/repos/iris-contrib/httpexpect" func (r *Request) WithPathObject(object interface{}) *Request { opChain := r.chain.enter("WithPathObject()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -908,7 +1046,7 @@ func (r *Request) withPath(opChain *chain, key string, value interface{}) { opChain.fail(AssertionFailure{ Type: AssertUsage, Errors: []error{ - errors.New("unexpected nil interpol argument"), + fmt.Errorf("unexpected nil interpol argument %q", k), }, }) } else { @@ -962,6 +1100,9 @@ func (r *Request) WithQuery(key string, value interface{}) *Request { opChain := r.chain.enter("WithQuery()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -999,8 +1140,8 @@ func (r *Request) WithQuery(key string, value interface{}) *Request { // Example: // // type MyURL struct { -// A int `url:"a"` -// B string `url:"b"` +// A int `url:"a"` +// B string `url:"b"` // } // // req := NewRequestC(config, "PUT", "http://example.com/path") @@ -1014,6 +1155,9 @@ func (r *Request) WithQueryObject(object interface{}) *Request { opChain := r.chain.enter("WithQueryObject()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1080,6 +1224,9 @@ func (r *Request) WithQueryString(query string) *Request { opChain := r.chain.enter("WithQueryString()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1126,6 +1273,9 @@ func (r *Request) WithURL(urlStr string) *Request { opChain := r.chain.enter("WithURL()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1158,12 +1308,15 @@ func (r *Request) WithURL(urlStr string) *Request { // // req := NewRequestC(config, "PUT", "http://example.com/path") // req.WithHeaders(map[string]string{ -// "Content-Type": "application/json", +// "Content-Type": "application/json", // }) func (r *Request) WithHeaders(headers map[string]string) *Request { opChain := r.chain.enter("WithHeaders()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1189,6 +1342,9 @@ func (r *Request) WithHeader(k, v string) *Request { opChain := r.chain.enter("WithHeader()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1226,13 +1382,16 @@ func (r *Request) withHeader(k, v string) { // // req := NewRequestC(config, "PUT", "http://example.com/path") // req.WithCookies(map[string]string{ -// "foo": "aa", -// "bar": "bb", +// "foo": "aa", +// "bar": "bb", // }) func (r *Request) WithCookies(cookies map[string]string) *Request { opChain := r.chain.enter("WithCookies()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1261,6 +1420,9 @@ func (r *Request) WithCookie(k, v string) *Request { opChain := r.chain.enter("WithCookie()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1291,6 +1453,9 @@ func (r *Request) WithBasicAuth(username, password string) *Request { opChain := r.chain.enter("WithBasicAuth()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1314,6 +1479,9 @@ func (r *Request) WithHost(host string) *Request { opChain := r.chain.enter("WithHost()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1339,6 +1507,9 @@ func (r *Request) WithProto(proto string) *Request { opChain := r.chain.enter("WithProto()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1386,6 +1557,9 @@ func (r *Request) WithChunked(reader io.Reader) *Request { opChain := r.chain.enter("WithChunked()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1422,6 +1596,9 @@ func (r *Request) WithBytes(b []byte) *Request { opChain := r.chain.enter("WithBytes()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1450,6 +1627,9 @@ func (r *Request) WithText(s string) *Request { opChain := r.chain.enter("WithText()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1470,7 +1650,7 @@ func (r *Request) WithText(s string) *Request { // Example: // // type MyJSON struct { -// Foo int `json:"foo"` +// Foo int `json:"foo"` // } // // req := NewRequestC(config, "PUT", "http://example.com/path") @@ -1482,6 +1662,9 @@ func (r *Request) WithJSON(object interface{}) *Request { opChain := r.chain.enter("WithJSON()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1524,7 +1707,7 @@ func (r *Request) WithJSON(object interface{}) *Request { // Example: // // type MyForm struct { -// Foo int `form:"foo"` +// Foo int `form:"foo"` // } // // req := NewRequestC(config, "PUT", "http://example.com/path") @@ -1536,6 +1719,9 @@ func (r *Request) WithForm(object interface{}) *Request { opChain := r.chain.enter("WithForm()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1604,11 +1790,14 @@ func (r *Request) WithForm(object interface{}) *Request { // // req := NewRequestC(config, "PUT", "http://example.com/path") // req.WithFormField("foo", 123). -// WithFormField("bar", 456) +// WithFormField("bar", 456) func (r *Request) WithFormField(key string, value interface{}) *Request { opChain := r.chain.enter("WithFormField()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1661,12 +1850,15 @@ func (r *Request) WithFormField(key string, value interface{}) *Request { // req := NewRequestC(config, "PUT", "http://example.com/path") // fh, _ := os.Open("./john.png") // req.WithMultipart(). -// WithFile("avatar", "john.png", fh) +// WithFile("avatar", "john.png", fh) // fh.Close() func (r *Request) WithFile(key, path string, reader ...io.Reader) *Request { opChain := r.chain.enter("WithFile()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1699,12 +1891,15 @@ func (r *Request) WithFile(key, path string, reader ...io.Reader) *Request { // fh, _ := os.Open("./john.png") // b, _ := ioutil.ReadAll(fh) // req.WithMultipart(). -// WithFileBytes("avatar", "john.png", b) +// WithFileBytes("avatar", "john.png", b) // fh.Close() func (r *Request) WithFileBytes(key, path string, data []byte) *Request { opChain := r.chain.enter("WithFileBytes()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1792,11 +1987,14 @@ func (r *Request) withFile( // // req := NewRequestC(config, "PUT", "http://example.com/path") // req.WithMultipart(). -// WithForm(map[string]interface{}{"foo": 123}) +// WithForm(map[string]interface{}{"foo": 123}) func (r *Request) WithMultipart() *Request { opChain := r.chain.enter("WithMultipart()") defer opChain.leave() + r.mu.Lock() + defer r.mu.Unlock() + if opChain.failed() { return r } @@ -1809,7 +2007,7 @@ func (r *Request) WithMultipart() *Request { if r.multipart == nil { r.formbuf = &bytes.Buffer{} - r.multipart = multipart.NewWriter(r.formbuf) + r.multipart = r.multipartFn(r.formbuf) r.setBody(opChain, "WithMultipart()", r.formbuf, 0, false) } @@ -1848,15 +2046,14 @@ func (r *Request) Expect() *Response { } func (r *Request) expect(opChain *chain) *Response { - if opChain.failed() { + if !r.prepare(opChain) { return nil } - if !r.checkOrder(opChain, "Expect()") { - return nil - } + // after return from prepare(), all subsequent calls to WithXXX and Expect will + // abort early due to checkOrder(); so we can safely proceed without a lock - resp := r.roundTrip(opChain) + resp := r.execute(opChain) if resp == nil { return nil @@ -1866,12 +2063,27 @@ func (r *Request) expect(opChain *chain) *Response { matcher(resp) } + return resp +} + +func (r *Request) prepare(opChain *chain) bool { + r.mu.Lock() + defer r.mu.Unlock() + + if opChain.failed() { + return false + } + + if !r.checkOrder(opChain, "Expect()") { + return false + } + r.expectCalled = true - return resp + return true } -func (r *Request) roundTrip(opChain *chain) *Response { +func (r *Request) execute(opChain *chain) *Response { if !r.encodeRequest(opChain) { return nil } @@ -1882,8 +2094,12 @@ func (r *Request) roundTrip(opChain *chain) *Response { } } - for _, transform := range r.transforms { + for _, transform := range r.transformers { transform(r.httpReq) + + if opChain.failed() { + return nil + } } var ( @@ -1911,10 +2127,6 @@ func (r *Request) roundTrip(opChain *chain) *Response { } func (r *Request) encodeRequest(opChain *chain) bool { - if opChain.failed() { - return false - } - r.httpReq.URL.Path = concatPaths(r.httpReq.URL.Path, r.path) if r.query != nil { @@ -1959,10 +2171,6 @@ var websocketErr = `webocket request can not have body: webocket was enabled by WithWebsocketUpgrade()` func (r *Request) encodeWebsocketRequest(opChain *chain) bool { - if opChain.failed() { - return false - } - if r.bodySetter != "" { opChain.fail(AssertionFailure{ Type: AssertUsage, @@ -1984,10 +2192,6 @@ func (r *Request) encodeWebsocketRequest(opChain *chain) bool { } func (r *Request) sendRequest(opChain *chain) (*http.Response, time.Duration) { - if opChain.failed() { - return nil, 0 - } - resp, elapsed, err := r.retryRequest(func() (*http.Response, error) { return r.config.Client.Do(r.httpReq) }) @@ -2009,10 +2213,6 @@ func (r *Request) sendRequest(opChain *chain) (*http.Response, time.Duration) { func (r *Request) sendWebsocketRequest(opChain *chain) ( *http.Response, *websocket.Conn, time.Duration, ) { - if opChain.failed() { - return nil, nil, 0 - } - var conn *websocket.Conn resp, elapsed, err := r.retryRequest(func() (resp *http.Response, err error) { conn, resp, err = r.config.WebsocketDialer.Dial( diff --git a/response.go b/response.go index 59f495d3d..bfa92b559 100644 --- a/response.go +++ b/response.go @@ -604,7 +604,7 @@ func (r *Response) NoContent() *Response { return r } -// ContentType succeeds if response contains Content-Type header with given +// HasContentType succeeds if response contains Content-Type header with given // media type and charset. // // If charset is omitted, and mediaType is non-empty, Content-Type header @@ -612,8 +612,8 @@ func (r *Response) NoContent() *Response { // // If charset is omitted, and mediaType is also empty, Content-Type header // should contain no charset. -func (r *Response) ContentType(mediaType string, charset ...string) *Response { - opChain := r.chain.enter("ContentType()") +func (r *Response) HasContentType(mediaType string, charset ...string) *Response { + opChain := r.chain.enter("HasContentType()") defer opChain.leave() if opChain.failed() { @@ -635,10 +635,10 @@ func (r *Response) ContentType(mediaType string, charset ...string) *Response { return r } -// ContentEncoding succeeds if response has exactly given Content-Encoding list. +// HasContentEncoding succeeds if response has exactly given Content-Encoding list. // Common values are empty, "gzip", "compress", "deflate", "identity" and "br". -func (r *Response) ContentEncoding(encoding ...string) *Response { - opChain := r.chain.enter("ContentEncoding()") +func (r *Response) HasContentEncoding(encoding ...string) *Response { + opChain := r.chain.enter("HasContentEncoding()") defer opChain.leave() if opChain.failed() { @@ -652,10 +652,10 @@ func (r *Response) ContentEncoding(encoding ...string) *Response { return r } -// TransferEncoding succeeds if response contains given Transfer-Encoding list. +// HasTransferEncoding succeeds if response contains given Transfer-Encoding list. // Common values are empty, "chunked" and "identity". -func (r *Response) TransferEncoding(encoding ...string) *Response { - opChain := r.chain.enter("TransferEncoding()") +func (r *Response) HasTransferEncoding(encoding ...string) *Response { + opChain := r.chain.enter("HasTransferEncoding()") defer opChain.leave() if opChain.failed() { @@ -669,6 +669,21 @@ func (r *Response) TransferEncoding(encoding ...string) *Response { return r } +// Deprecated: use HasContentType instead. +func (r *Response) ContentType(mediaType string, charset ...string) *Response { + return r.HasContentType(mediaType, charset...) +} + +// Deprecated: use HasContentEncoding instead. +func (r *Response) ContentEncoding(encoding ...string) *Response { + return r.HasContentEncoding(encoding...) +} + +// Deprecated: use HasTransferEncoding instead. +func (r *Response) TransferEncoding(encoding ...string) *Response { + return r.HasTransferEncoding(encoding...) +} + // ContentOpts define parameters for matching the response content parameters. type ContentOpts struct { // The media type Content-Type part, e.g. "application/json" diff --git a/stacktrace.go b/stacktrace.go new file mode 100644 index 000000000..3e6c1759f --- /dev/null +++ b/stacktrace.go @@ -0,0 +1,66 @@ +package httpexpect + +import ( + "regexp" + "runtime" +) + +// Stacktrace entry. +type StacktraceEntry struct { + Pc uintptr // Program counter + + File string // File path + Line int // Line number + + Func *runtime.Func // Function information + + FuncName string // Function name (without package and parenthesis) + FuncPackage string // Function package + FuncOffset uintptr // Program counter offset relative to function start + + // True if this is program entry point + // (like main.main or testing.tRunner) + IsEntrypoint bool +} + +var stacktraceFuncRe = regexp.MustCompile(`^(.+/[^.]+)\.(.+)$`) + +func stacktrace() []StacktraceEntry { + callers := []StacktraceEntry{} + for i := 1; ; i++ { + pc, file, line, ok := runtime.Caller(i) + if !ok { + break + } + + f := runtime.FuncForPC(pc) + if f == nil { + break + } + + entry := StacktraceEntry{ + Pc: pc, + File: file, + Line: line, + Func: f, + } + + if m := stacktraceFuncRe.FindStringSubmatch(f.Name()); m != nil { + entry.FuncName = m[2] + entry.FuncPackage = m[1] + } else { + entry.FuncName = f.Name() + } + + entry.FuncOffset = pc - f.Entry() + + switch f.Name() { + case "main.main", "testing.tRunner": + entry.IsEntrypoint = true + } + + callers = append(callers, entry) + } + + return callers +} diff --git a/value.go b/value.go index 6d14f26b8..801de234a 100644 --- a/value.go +++ b/value.go @@ -163,7 +163,7 @@ func (v *Value) Alias(name string) *Value { // value := NewValue(t, json) // // for _, user := range value.Path("$..user").Array().Iter() { -// user.String().IsEqual("john") +// user.String().IsEqual("john") // } func (v *Value) Path(path string) *Value { opChain := v.chain.enter("Path(%q)", path) @@ -189,19 +189,19 @@ func (v *Value) Path(path string) *Value { // schema := `{ // "type": "object", // "properties": { -// "foo": { -// "type": "string" -// }, -// "bar": { -// "type": "integer" -// } +// "foo": { +// "type": "string" +// }, +// "bar": { +// "type": "integer" +// } // }, // "require": ["foo", "bar"] // }` // // value := NewValue(t, map[string]interface{}{ -// "foo": "a", -// "bar": 1, +// "foo": "a", +// "bar": 1, // }) // // value.Schema(schema) diff --git a/websocket.go b/websocket.go index 6884e075f..8e773f049 100644 --- a/websocket.go +++ b/websocket.go @@ -179,7 +179,7 @@ func (ws *Websocket) Subprotocol() *String { // Example: // // msg := conn.Expect() -// msg.JSON().Object().IsValueEqual("message", "hi") +// msg.JSON().Object().HasValue("message", "hi") func (ws *Websocket) Expect() *WebsocketMessage { opChain := ws.chain.enter("Expect()") defer opChain.leave() diff --git a/websocket_message.go b/websocket_message.go index 48d40b255..23b27c375 100644 --- a/websocket_message.go +++ b/websocket_message.go @@ -63,9 +63,22 @@ func newWebsocketMessage( ) *WebsocketMessage { wm := newEmptyWebsocketMessage(parent) + opChain := wm.chain.enter("") + defer opChain.leave() + wm.typ = typ wm.content = content + if len(closeCode) > 1 { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected multiple closeCode arguments"), + }, + }) + return wm + } + if len(closeCode) != 0 { wm.closeCode = closeCode[0] } @@ -458,24 +471,6 @@ func (wm *WebsocketMessage) checkNotCode(opChain *chain, codes ...int) { } } -// Body returns a new String instance with WebSocket message content. -// -// Example: -// -// msg := conn.Expect() -// msg.Body().NotEmpty() -// msg.Body().Length().IsEqual(100) -func (wm *WebsocketMessage) Body() *String { - opChain := wm.chain.enter("Body()") - defer opChain.leave() - - if opChain.failed() { - return newString(opChain, "") - } - - return newString(opChain, string(wm.content)) -} - // NoContent succeeds if WebSocket message has no content (is empty). func (wm *WebsocketMessage) NoContent() *WebsocketMessage { opChain := wm.chain.enter("NoContent()") @@ -507,6 +502,24 @@ func (wm *WebsocketMessage) NoContent() *WebsocketMessage { return wm } +// Body returns a new String instance with WebSocket message content. +// +// Example: +// +// msg := conn.Expect() +// msg.Body().NotEmpty() +// msg.Body().Length().IsEqual(100) +func (wm *WebsocketMessage) Body() *String { + opChain := wm.chain.enter("Body()") + defer opChain.leave() + + if opChain.failed() { + return newString(opChain, "") + } + + return newString(opChain, string(wm.content)) +} + // JSON returns a new Value instance with JSON contents of WebSocket message. // // JSON succeeds if JSON may be decoded from message content.