diff --git a/features/formatter/cucumber.feature b/features/formatter/cucumber.feature new file mode 100644 index 0000000..78a499e --- /dev/null +++ b/features/formatter/cucumber.feature @@ -0,0 +1,551 @@ +Feature: cucumber json formatter + In order to support tools that import cucumber json output + I need to be able to support cucumber json formatted output + + Scenario: Support of Feature Plus Scenario Node + Given a feature "features/simple.feature" file: + """ + Feature: simple feature + simple feature description + Scenario: simple scenario + simple scenario description + """ + When I run feature suite with formatter "cucumber" + Then the rendered json will be as follows: + """ application/json + [ + { + "uri": "features/simple.feature", + "id": "simple-feature", + "keyword": "Feature", + "name": "simple feature", + "description": " simple feature description", + "line": 1, + "elements": [ + { + "id": "simple-feature;simple-scenario", + "keyword": "Scenario", + "name": "simple scenario", + "description": " simple scenario description", + "line": 3, + "type": "scenario" + } + ] + } + ] + """ + + Scenario: Support of Feature Plus Scenario Node With Tags + Given a feature "features/simple.feature" file: + """ + @TAG1 + Feature: simple feature + simple feature description + @TAG2 @TAG3 + Scenario: simple scenario + simple scenario description + """ + When I run feature suite with formatter "cucumber" + Then the rendered json will be as follows: + """ application/json + [ + { + "uri": "features/simple.feature", + "id": "simple-feature", + "keyword": "Feature", + "name": "simple feature", + "description": " simple feature description", + "line": 2, + "tags": [ + { + "name": "@TAG1", + "line": 1 + } + ], + "elements": [ + { + "id": "simple-feature;simple-scenario", + "keyword": "Scenario", + "name": "simple scenario", + "description": " simple scenario description", + "line": 5, + "type": "scenario", + "tags": [ + { + "name": "@TAG1", + "line": 1 + }, + { + "name": "@TAG2", + "line": 4 + }, + { + "name": "@TAG3", + "line": 4 + } + ] + } + ] + } + ] + """ + Scenario: Support of Feature Plus Scenario Outline + Given a feature "features/simple.feature" file: + """ + Feature: simple feature + simple feature description + + Scenario Outline: simple scenario + simple scenario description + + Examples: simple examples + | status | + | pass | + | fail | + """ + When I run feature suite with formatter "cucumber" + Then the rendered json will be as follows: + """ + [ + { + "uri": "features/simple.feature", + "id": "simple-feature", + "keyword": "Feature", + "name": "simple feature", + "description": " simple feature description", + "line": 1, + "elements": [ + { + "id": "simple-feature;simple-scenario;simple-examples;2", + "keyword": "Scenario Outline", + "name": "simple scenario", + "description": " simple scenario description", + "line": 9, + "type": "scenario" + }, + { + "id": "simple-feature;simple-scenario;simple-examples;3", + "keyword": "Scenario Outline", + "name": "simple scenario", + "description": " simple scenario description", + "line": 10, + "type": "scenario" + } + ] + } + ] + """ + + Scenario: Support of Feature Plus Scenario Outline With Tags + Given a feature "features/simple.feature" file: + """ + @TAG1 + Feature: simple feature + simple feature description + + @TAG2 + Scenario Outline: simple scenario + simple scenario description + + @TAG3 + Examples: simple examples + | status | + | pass | + | fail | + """ + When I run feature suite with formatter "cucumber" + Then the rendered json will be as follows: + """ + [ + { + "uri": "features/simple.feature", + "id": "simple-feature", + "keyword": "Feature", + "name": "simple feature", + "description": " simple feature description", + "line": 2, + "tags": [ + { + "name": "@TAG1", + "line": 1 + } + ], + "elements": [ + { + "id": "simple-feature;simple-scenario;simple-examples;2", + "keyword": "Scenario Outline", + "name": "simple scenario", + "description": " simple scenario description", + "line": 12, + "type": "scenario", + "tags": [ + { + "name": "@TAG1", + "line": 1 + }, + { + "name": "@TAG2", + "line": 5 + }, + { + "name": "@TAG3", + "line": 9 + } + ] + }, + { + "id": "simple-feature;simple-scenario;simple-examples;3", + "keyword": "Scenario Outline", + "name": "simple scenario", + "description": " simple scenario description", + "line": 13, + "type": "scenario", + "tags": [ + { + "name": "@TAG1", + "line": 1 + }, + { + "name": "@TAG2", + "line": 5 + }, + { + "name": "@TAG3", + "line": 9 + } + ] + } + ] + } + ] + """ + Scenario: Support of Feature Plus Scenario With Steps + Given a feature "features/simple.feature" file: + """ + Feature: simple feature + simple feature description + + Scenario: simple scenario + simple scenario description + + Given passing step + Then a failing step + + """ + When I run feature suite with formatter "cucumber" + Then the rendered json will be as follows: + """ + [ + { + "uri": "features/simple.feature", + "id": "simple-feature", + "keyword": "Feature", + "name": "simple feature", + "description": " simple feature description", + "line": 1, + "elements": [ + { + "id": "simple-feature;simple-scenario", + "keyword": "Scenario", + "name": "simple scenario", + "description": " simple scenario description", + "line": 4, + "type": "scenario", + "steps": [ + { + "keyword": "Given ", + "name": "passing step", + "line": 7, + "match": { + "location": "STEP_ID" + }, + "result": { + "status": "passed", + "duration": -1 + } + }, + { + "keyword": "Then ", + "name": "a failing step", + "line": 8, + "match": { + "location": "STEP_ID" + }, + "result": { + "status": "failed", + "error_message": "intentional failure", + "duration": -1 + } + } + ] + } + ] + } + ] + """ + Scenario: Support of Feature Plus Scenario Outline With Steps + Given a feature "features/simple.feature" file: + """ + Feature: simple feature + simple feature description + + Scenario Outline: simple scenario + simple scenario description + + Given step + + Examples: simple examples + | status | + | passing | + | failing | + + """ + When I run feature suite with formatter "cucumber" + Then the rendered json will be as follows: + """ + [ + { + "uri": "features/simple.feature", + "id": "simple-feature", + "keyword": "Feature", + "name": "simple feature", + "description": " simple feature description", + "line": 1, + "elements": [ + { + "id": "simple-feature;simple-scenario;simple-examples;2", + "keyword": "Scenario Outline", + "name": "simple scenario", + "description": " simple scenario description", + "line": 11, + "type": "scenario", + "steps": [ + { + "keyword": "Given ", + "name": "passing step", + "line": 11, + "match": { + "location": "STEP_ID" + }, + "result": { + "status": "passed", + "duration": -1 + } + } + ] + }, + { + "id": "simple-feature;simple-scenario;simple-examples;3", + "keyword": "Scenario Outline", + "name": "simple scenario", + "description": " simple scenario description", + "line": 12, + "type": "scenario", + "steps": [ + { + "keyword": "Given ", + "name": "failing step", + "line": 12, + "match": { + "location": "STEP_ID" + }, + "result": { + "status": "failed", + "error_message": "intentional failure", + "duration": -1 + } + } + ] + } + ] + } + ] + """ + + # Currently godog only supports comments on Feature and not + # scenario and steps. + Scenario: Support of Comments + Given a feature "features/simple.feature" file: + """ + #Feature comment + Feature: simple feature + simple description + + Scenario: simple scenario + simple feature description + """ + When I run feature suite with formatter "cucumber" + Then the rendered json will be as follows: + """ + [ + { + "uri": "features/simple.feature", + "id": "simple-feature", + "keyword": "Feature", + "name": "simple feature", + "description": " simple description", + "line": 2, + "comments": [ + { + "value": "#Feature comment", + "line": 1 + } + ], + "elements": [ + { + "id": "simple-feature;simple-scenario", + "keyword": "Scenario", + "name": "simple scenario", + "description": " simple feature description", + "line": 5, + "type": "scenario" + } + ] + } + ] + """ + Scenario: Support of Docstrings + Given a feature "features/simple.feature" file: + """ + Feature: simple feature + simple description + + Scenario: simple scenario + simple feature description + + Given passing step + \"\"\" content type + step doc string + \"\"\" + """ + When I run feature suite with formatter "cucumber" + Then the rendered json will be as follows: + """ + [ + { + "uri": "features/simple.feature", + "id": "simple-feature", + "keyword": "Feature", + "name": "simple feature", + "description": " simple description", + "line": 1, + "elements": [ + { + "id": "simple-feature;simple-scenario", + "keyword": "Scenario", + "name": "simple scenario", + "description": " simple feature description", + "line": 4, + "type": "scenario", + "steps": [ + { + "keyword": "Given ", + "name": "passing step", + "line": 7, + "doc_string": { + "value": "step doc string", + "content_type": "content type", + "line": 8 + }, + "match": { + "location": "STEP_ID" + }, + "result": { + "status": "passed", + "duration": -1 + } + } + ] + } + ] + } + ] + """ + Scenario: Support of Undefined, Pending and Skipped status + Given a feature "features/simple.feature" file: + """ + Feature: simple feature + simple feature description + + Scenario: simple scenario + simple scenario description + + Given passing step + And pending step + And undefined + And passing step + + """ + When I run feature suite with formatter "cucumber" + Then the rendered json will be as follows: + """ + [ + { + "uri": "features/simple.feature", + "id": "simple-feature", + "keyword": "Feature", + "name": "simple feature", + "description": " simple feature description", + "line": 1, + "elements": [ + { + "id": "simple-feature;simple-scenario", + "keyword": "Scenario", + "name": "simple scenario", + "description": " simple scenario description", + "line": 4, + "type": "scenario", + "steps": [ + { + "keyword": "Given ", + "name": "passing step", + "line": 7, + "match": { + "location": "STEP_ID" + }, + "result": { + "status": "passed", + "duration": 397087 + } + }, + { + "keyword": "And ", + "name": "pending step", + "line": 8, + "match": { + "location": "FEATURE_PATH features/simple.feature:8" + }, + "result": { + "status": "pending" + } + }, + { + "keyword": "And ", + "name": "undefined", + "line": 9, + "match": { + "location": "FEATURE_PATH features/simple.feature:9" + }, + "result": { + "status": "undefined" + } + }, + { + "keyword": "And ", + "name": "passing step", + "line": 10, + "match": { + "location": "STEP_ID" + }, + "result": { + "status": "skipped" + } + } + ] + } + ] + } + ] + """ + + diff --git a/features/formatter/events.feature b/features/formatter/events.feature index d9c2b2a..cd99b33 100644 --- a/features/formatter/events.feature +++ b/features/formatter/events.feature @@ -14,7 +14,7 @@ Feature: event stream formatter """ Scenario: should process simple scenario - Given a feature path "features/load.feature:21" + Given a feature path "features/load.feature:22" When I run feature suite with formatter "events" Then the following events should be fired: """ @@ -35,7 +35,7 @@ Feature: event stream formatter """ Scenario: should process outline scenario - Given a feature path "features/load.feature:29" + Given a feature path "features/load.feature:30" When I run feature suite with formatter "events" Then the following events should be fired: """ diff --git a/features/lang.feature b/features/lang.feature index 0cf35a0..829a939 100644 --- a/features/lang.feature +++ b/features/lang.feature @@ -8,10 +8,11 @@ Savybė: užkrauti savybes Scenarijus: savybių užkrovimas iš aplanko Duota savybių aplankas "features" Kai aš išskaitau savybes - Tada aš turėčiau turėti 8 savybių failus: + Tada aš turėčiau turėti 9 savybių failus: """ features/background.feature features/events.feature + features/formatter/cucumber.feature features/formatter/events.feature features/lang.feature features/load.feature diff --git a/features/load.feature b/features/load.feature index f15cee1..22ed683 100644 --- a/features/load.feature +++ b/features/load.feature @@ -6,10 +6,11 @@ Feature: load features Scenario: load features within path Given a feature path "features" When I parse features - Then I should have 8 feature files: + Then I should have 9 feature files: """ features/background.feature features/events.feature + features/formatter/cucumber.feature features/formatter/events.feature features/lang.feature features/load.feature diff --git a/fmt_cucumber.go b/fmt_cucumber.go new file mode 100644 index 0000000..52a00cc --- /dev/null +++ b/fmt_cucumber.go @@ -0,0 +1,333 @@ +package godog + +/* + The specification for the formatting originated from https://www.relishapp.com/cucumber/cucumber/docs/formatters/json-output-formatter. + I found that documentation was misleading or out dated. To validate formatting I create a ruby cucumber test harness and ran the + same feature files through godog and the ruby cucumber. + + The docstrings in the cucumber.feature represent the cucumber output for those same feature definitions. + + I did note that comments in ruby could be at just about any level in particular Feature, Scenario and Step. In godog I + could only find comments under the Feature data structure. +*/ + +import ( + "encoding/json" + "fmt" + "github.com/DATA-DOG/godog/gherkin" + "io" + "strconv" + "strings" + "time" +) + +const cukeurl = "https://www.relishapp.com/cucumber/cucumber/docs/formatters/json-output-formatter" + +func init() { + Format("cucumber", fmt.Sprintf("Produces cucumber JSON stream, based on spec @: %s.", cukeurl), cucumberFunc) +} + +func cucumberFunc(suite string, out io.Writer) Formatter { + formatter := &cukefmt{ + basefmt: basefmt{ + started: time.Now(), + indent: 2, + out: out, + }, + } + + return formatter +} + +// Replace spaces with - This function is used to create the "id" fields of the cucumber output. +func makeID(name string) string { + return strings.Replace(strings.ToLower(name), " ", "-", -1) +} + +// The sequence of type structs are used to marshall the json object. +type cukeComment struct { + Value string `json:"value"` + Line int `json:"line"` +} + +type cukeDocstring struct { + Value string `json:"value"` + ContentType string `json:"content_type"` + Line int `json:"line"` +} + +type cukeTag struct { + Name string `json:"name"` + Line int `json:"line"` +} + +type cukeResult struct { + Status string `json:"status"` + Error string `json:"error_message,omitempty"` + Duration *int `json:"duration,omitempty"` +} + +type cukeMatch struct { + Location string `json:"location"` +} + +type cukeStep struct { + Keyword string `json:"keyword"` + Name string `json:"name"` + Line int `json:"line"` + Docstring *cukeDocstring `json:"doc_string,omitempty"` + Match cukeMatch `json:"match"` + Result cukeResult `json:"result"` +} + +type cukeElement struct { + ID string `json:"id"` + Keyword string `json:"keyword"` + Name string `json:"name"` + Description string `json:"description"` + Line int `json:"line"` + Type string `json:"type"` + Tags []cukeTag `json:"tags,omitempty"` + Steps []cukeStep `json:"steps,omitempty"` +} + +type cukeFeatureJSON struct { + URI string `json:"uri"` + ID string `json:"id"` + Keyword string `json:"keyword"` + Name string `json:"name"` + Description string `json:"description"` + Line int `json:"line"` + Comments []cukeComment `json:"comments,omitempty"` + Tags []cukeTag `json:"tags,omitempty"` + Elements []cukeElement `json:"elements,omitempty"` +} + +type cukefmt struct { + basefmt + + // currently running feature path, to be part of id. + // this is sadly not passed by gherkin nodes. + // it restricts this formatter to run only in synchronous single + // threaded execution. Unless running a copy of formatter for each feature + path string + stat stepType // last step status, before skipped + outlineSteps int // number of current outline scenario steps + ID string // current test id. + results []cukeFeatureJSON // structure that represent cuke results + curStep *cukeStep // track the current step + curElement *cukeElement // track the current element + curFeature *cukeFeatureJSON // track the current feature + curOutline cukeElement // Each example show up as an outline element but the outline is parsed only once + // so I need to keep track of the current outline + curRow int // current row of the example table as it is being processed. + curExampleTags []cukeTag // temporary storage for tags associate with the current example table. + startTime time.Time // used to time duration of the step execution + curExampleName string // Due to the fact that examples are parsed once and then iterated over for each result then we need to keep track + // of the example name inorder to build id fields. +} + +func (f *cukefmt) Node(n interface{}) { + f.basefmt.Node(n) + + switch t := n.(type) { + + // When the example definition is seen we just need track the id and + // append the name associated with the example as part of the id. + case *gherkin.Examples: + + f.curExampleName = makeID(t.Name) + f.curRow = 2 // there can be more than one example set per outline so reset row count. + // cucumber counts the header row as an example when creating the id. + + // store any example level tags in a temp location. + f.curExampleTags = make([]cukeTag, len(t.Tags)) + for idx, element := range t.Tags { + f.curExampleTags[idx].Line = element.Location.Line + f.curExampleTags[idx].Name = element.Name + } + + // The outline node creates a placeholder and the actual element is added as each TableRow is processed. + case *gherkin.ScenarioOutline: + + f.curOutline = cukeElement{} + f.curOutline.Name = t.Name + f.curOutline.Line = t.Location.Line + f.curOutline.Description = t.Description + f.curOutline.Keyword = t.Keyword + f.curOutline.ID = f.curFeature.ID + ";" + makeID(t.Name) + f.curOutline.Type = "scenario" + f.curOutline.Tags = make([]cukeTag, len(t.Tags)+len(f.curFeature.Tags)) + + // apply feature level tags + if len(f.curOutline.Tags) > 0 { + copy(f.curOutline.Tags, f.curFeature.Tags) + + // apply outline level tags. + for idx, element := range t.Tags { + f.curOutline.Tags[idx+len(f.curFeature.Tags)].Line = element.Location.Line + f.curOutline.Tags[idx+len(f.curFeature.Tags)].Name = element.Name + } + } + + // This scenario adds the element to the output immediately. + case *gherkin.Scenario: + f.curFeature.Elements = append(f.curFeature.Elements, cukeElement{}) + f.curElement = &f.curFeature.Elements[len(f.curFeature.Elements)-1] + + f.curElement.Name = t.Name + f.curElement.Line = t.Location.Line + f.curElement.Description = t.Description + f.curElement.Keyword = t.Keyword + f.curElement.ID = f.curFeature.ID + ";" + makeID(t.Name) + f.curElement.Type = "scenario" + f.curElement.Tags = make([]cukeTag, len(t.Tags)+len(f.curFeature.Tags)) + + if len(f.curElement.Tags) > 0 { + // apply feature level tags + copy(f.curElement.Tags, f.curFeature.Tags) + + // apply scenario level tags. + for idx, element := range t.Tags { + f.curElement.Tags[idx+len(f.curFeature.Tags)].Line = element.Location.Line + f.curElement.Tags[idx+len(f.curFeature.Tags)].Name = element.Name + } + } + + // This is an outline scenario and the element is added to the output as + // the TableRows are encountered. + case *gherkin.TableRow: + tmpElem := f.curOutline + tmpElem.Line = t.Location.Line + tmpElem.ID = tmpElem.ID + ";" + f.curExampleName + ";" + strconv.Itoa(f.curRow) + f.curRow++ + f.curFeature.Elements = append(f.curFeature.Elements, tmpElem) + f.curElement = &f.curFeature.Elements[len(f.curFeature.Elements)-1] + + // copy in example level tags. + f.curElement.Tags = append(f.curElement.Tags, f.curExampleTags...) + + } + +} + +func (f *cukefmt) Feature(ft *gherkin.Feature, p string, c []byte) { + + f.basefmt.Feature(ft, p, c) + f.path = p + f.ID = makeID(ft.Name) + f.results = append(f.results, cukeFeatureJSON{}) + + f.curFeature = &f.results[len(f.results)-1] + f.curFeature.URI = p + f.curFeature.Name = ft.Name + f.curFeature.Keyword = ft.Keyword + f.curFeature.Line = ft.Location.Line + f.curFeature.Description = ft.Description + f.curFeature.ID = f.ID + f.curFeature.Tags = make([]cukeTag, len(ft.Tags)) + + for idx, element := range ft.Tags { + f.curFeature.Tags[idx].Line = element.Location.Line + f.curFeature.Tags[idx].Name = element.Name + } + + f.curFeature.Comments = make([]cukeComment, len(ft.Comments)) + for idx, comment := range ft.Comments { + f.curFeature.Comments[idx].Value = strings.TrimSpace(comment.Text) + f.curFeature.Comments[idx].Line = comment.Location.Line + } + +} + +func (f *cukefmt) Summary() { + dat, err := json.MarshalIndent(f.results, "", " ") + if err != nil { + panic(err) + } + fmt.Fprintf(f.out, "%s\n", string(dat)) +} + +func (f *cukefmt) step(res *stepResult) { + + // determine if test case has finished + switch t := f.owner.(type) { + case *gherkin.TableRow: + d := int(time.Since(f.startTime).Nanoseconds()) + f.curStep.Result.Duration = &d + f.curStep.Line = t.Location.Line + f.curStep.Result.Status = res.typ.String() + if res.err != nil { + f.curStep.Result.Error = res.err.Error() + } + case *gherkin.Scenario: + d := int(time.Since(f.startTime).Nanoseconds()) + f.curStep.Result.Duration = &d + f.curStep.Result.Status = res.typ.String() + if res.err != nil { + f.curStep.Result.Error = res.err.Error() + } + } +} + +func (f *cukefmt) Defined(step *gherkin.Step, def *StepDef) { + + f.startTime = time.Now() // start timing the step + f.curElement.Steps = append(f.curElement.Steps, cukeStep{}) + f.curStep = &f.curElement.Steps[len(f.curElement.Steps)-1] + + f.curStep.Name = step.Text + f.curStep.Line = step.Location.Line + f.curStep.Keyword = step.Keyword + + if _, ok := step.Argument.(*gherkin.DocString); ok { + f.curStep.Docstring = &cukeDocstring{} + f.curStep.Docstring.ContentType = strings.TrimSpace(step.Argument.(*gherkin.DocString).ContentType) + f.curStep.Docstring.Line = step.Argument.(*gherkin.DocString).Location.Line + f.curStep.Docstring.Value = step.Argument.(*gherkin.DocString).Content + } + + if def != nil { + f.curStep.Match.Location = strings.Split(def.definitionID(), " ")[0] + } +} + +func (f *cukefmt) Passed(step *gherkin.Step, match *StepDef) { + f.basefmt.Passed(step, match) + f.stat = passed + f.step(f.passed[len(f.passed)-1]) +} + +func (f *cukefmt) Skipped(step *gherkin.Step) { + f.basefmt.Skipped(step) + f.step(f.skipped[len(f.skipped)-1]) + + // no duration reported for skipped. + f.curStep.Result.Duration = nil +} + +func (f *cukefmt) Undefined(step *gherkin.Step) { + f.basefmt.Undefined(step) + f.stat = undefined + f.step(f.undefined[len(f.undefined)-1]) + + // the location for undefined is the feature file location not the step file. + f.curStep.Match.Location = fmt.Sprintf("%s:%d", f.path, step.Location.Line) + f.curStep.Result.Duration = nil +} + +func (f *cukefmt) Failed(step *gherkin.Step, match *StepDef, err error) { + f.basefmt.Failed(step, match, err) + f.stat = failed + f.step(f.failed[len(f.failed)-1]) +} + +func (f *cukefmt) Pending(step *gherkin.Step, match *StepDef) { + f.stat = pending + f.basefmt.Pending(step, match) + f.step(f.pending[len(f.pending)-1]) + + // the location for pending is the feature file location not the step file. + f.curStep.Match.Location = fmt.Sprintf("%s:%d", f.path, step.Location.Line) + f.curStep.Result.Duration = nil +} diff --git a/run.go b/run.go index eea6b6a..cb73447 100644 --- a/run.go +++ b/run.go @@ -157,6 +157,9 @@ func supportsConcurrency(format string) bool { return false case "pretty": return false + case "cucumber": + return false } + return true // all custom formatters are treated as supporting concurrency } diff --git a/suite_test.go b/suite_test.go index 638a0c6..e8c3f9e 100644 --- a/suite_test.go +++ b/suite_test.go @@ -4,13 +4,13 @@ import ( "bytes" "encoding/json" "fmt" + "github.com/DATA-DOG/godog/gherkin" "os" "path/filepath" + "reflect" "strconv" "strings" "testing" - - "github.com/DATA-DOG/godog/gherkin" ) func TestMain(m *testing.M) { @@ -66,6 +66,15 @@ func SuiteContext(s *Suite) { s.Step(`^passing step$`, func() error { return nil }) + + // duplicate step to 'a failing step' I added to help test cucumber.feature + // I needed to have an Scenario Outline where the status was passing or failing + // I needed the same step def language. + s.Step(`^failing step$`, c.aFailingStep) + + // Introduced to test formatter/cucumber.feature + s.Step(`^the rendered json will be as follows:$`, c.theRenderJSONWillBe) + } type firedEvent struct { @@ -185,7 +194,7 @@ func (s *suiteContext) followingStepsShouldHave(status string, steps *gherkin.Do } if len(expected) > len(actual) { - return fmt.Errorf("number of expected %s steps: %d is less than actual %s steps: %d", status, len(expected), status, len(actual)) + return fmt.Errorf("number of expeted %s steps: %d is less than actual %s steps: %d", status, len(expected), status, len(actual)) } for _, a := range actual { @@ -376,3 +385,151 @@ func (s *suiteContext) theseEventsHadToBeFiredForNumberOfTimes(tbl *gherkin.Data } return nil } + +func (s *suiteContext) theRenderJSONWillBe(docstring *gherkin.DocString) error { + + var expected interface{} + if err := json.Unmarshal([]byte(docstring.Content), &expected); err != nil { + return err + } + + var actual interface{} + if err := json.Unmarshal(s.out.Bytes(), &actual); err != nil { + return err + } + + expectedArr := expected.([]interface{}) + actualArr := actual.([]interface{}) + + // Created to use in error reporting. + expectedCompact := &bytes.Buffer{} + actualCompact := &bytes.Buffer{} + json.Compact(expectedCompact, []byte(docstring.Content)) + json.Compact(actualCompact, s.out.Bytes()) + + for idx, entry := range expectedArr { + + // Make sure all of the expected are in the actual + if err := s.mapCompareStructure(entry.(map[string]interface{}), actualArr[idx].(map[string]interface{})); err != nil { + return fmt.Errorf("err:%v actual result is missing fields: expected:%s actual:%s", err, expectedCompact, actualCompact) + } + + // Make sure all of actual are in expected + if err := s.mapCompareStructure(actualArr[idx].(map[string]interface{}), entry.(map[string]interface{})); err != nil { + return fmt.Errorf("err:%v actual result contains too many fields: expected:%s actual:%s", err, expectedCompact, actualCompact) + } + + // Make sure the values are correct + if err := s.mapCompare(entry.(map[string]interface{}), actualArr[idx].(map[string]interface{})); err != nil { + return fmt.Errorf("err:%v values don't match expected:%s actual:%s", err, expectedCompact, actualCompact) + } + } + return nil +} + +/* + Due to specialize matching logic to ignore exact matches on the "location" and "duration" fields. It was + necessary to create this compare function to validate the values of the map. +*/ +func (s *suiteContext) mapCompare(expected map[string]interface{}, actual map[string]interface{}) error { + + // Process all keys in the map and handle them based on the type of the field. + for k, v := range expected { + + if actual[k] == nil { + return fmt.Errorf("No matching field in actual:[%s] expected value:[%v]", k, v) + } + // Process other maps via recursion + if reflect.TypeOf(v).Kind() == reflect.Map { + if err := s.mapCompare(v.(map[string]interface{}), actual[k].(map[string]interface{})); err != nil { + return err + } + // This is an array of maps show as a slice + } else if reflect.TypeOf(v).Kind() == reflect.Slice { + for i, e := range v.([]interface{}) { + if err := s.mapCompare(e.(map[string]interface{}), actual[k].([]interface{})[i].(map[string]interface{})); err != nil { + return err + } + } + // We need special rules to check location so that we are not bound to version of the code. + } else if k == "location" { + + // location is tricky. the cucumber value is either a the step def location for passed,failed, and skipped. + // it is the feature file location for undefined and skipped. + // I dont have the result context readily available so the expected input will have + // the context i need contained within its value. + // FEATURE_PATH myfile.feature:20 or + // STEP_ID + t := strings.Split(v.(string), " ") + if t[0] == "FEATURE_PATH" { + if actual[k].(string) != t[1] { + return fmt.Errorf("location has unexpected value [%s] should be [%s]", + actual[k], t[1]) + } + + } else if t[0] == "STEP_ID" { + if !strings.Contains(actual[k].(string), "suite_test.go:") { + return fmt.Errorf("location has unexpected filename [%s] should contain suite_test.go", + actual[k]) + } + + } else { + return fmt.Errorf("Bad location value [%v]", v) + } + + // We need special rules to validate duration too. + } else if k == "duration" { + if actual[k].(float64) <= 0 { + return fmt.Errorf("duration is <= zero: actual:[%v]", actual[k]) + } + // default numbers in json are coming as float64 + } else if reflect.TypeOf(v).Kind() == reflect.Float64 { + if v.(float64) != actual[k].(float64) { + if v.(float64) != actual[k].(float64) { + return fmt.Errorf("Field:[%s] not matching expected:[%v] actual:[%v]", + k, v, actual[k]) + } + } + + } else if reflect.TypeOf(v).Kind() == reflect.String { + if v.(string) != actual[k].(string) { + return fmt.Errorf("Field:[%s] not matching expected:[%v] actual:[%v]", + k, v, actual[k]) + } + } else { + return fmt.Errorf("Unexepcted type encountered in json at key:[%s] Type:[%v]", k, reflect.TypeOf(v).Kind()) + } + } + + return nil +} + +/* + Due to specialize matching logic to ignore exact matches on the "location" and "duration" fields. It was + necessary to create this compare function to validate the values of the map. +*/ +func (s *suiteContext) mapCompareStructure(expected map[string]interface{}, actual map[string]interface{}) error { + + // Process all keys in the map and handle them based on the type of the field. + for k, v := range expected { + + if actual[k] == nil { + return fmt.Errorf("Structure Mismatch: no matching field:[%s] expected value:[%v]", k, v) + } + // Process other maps via recursion + if reflect.TypeOf(v).Kind() == reflect.Map { + if err := s.mapCompareStructure(v.(map[string]interface{}), actual[k].(map[string]interface{})); err != nil { + return err + } + // This is an array of maps show as a slice + } else if reflect.TypeOf(v).Kind() == reflect.Slice { + for i, e := range v.([]interface{}) { + if err := s.mapCompareStructure(e.(map[string]interface{}), actual[k].([]interface{})[i].(map[string]interface{})); err != nil { + return err + } + } + } + } + + return nil +}