diff --git a/.gitignore b/.gitignore index 76481388..bd77fc9f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ Gopkg.toml .idea .vscode -_artifacts \ No newline at end of file +_artifacts + +vendor diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b6e3dcb..01b8612f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This document is formatted according to the principles of [Keep A CHANGELOG](htt ## [v0.14.1] ### Added +- Provide support for attachments / embeddings - ([623](https://github.com/cucumber/godog/pull/623) - [johnlon](https://github.com/johnlon)) - Provide testing.T-compatible interface on test context, allowing usage of assertion libraries such as testify's assert/require - ([571](https://github.com/cucumber/godog/pull/571) - [mrsheepuk](https://github.com/mrsheepuk)) - Created releasing guidelines - ([608](https://github.com/cucumber/godog/pull/608) - [glibas](https://github.com/glibas)) diff --git a/README.md b/README.md index 1bbf2087..df52ca19 100644 --- a/README.md +++ b/README.md @@ -580,3 +580,6 @@ A simple example can be [found here](/_examples/custom-formatter). [contributing guide]: https://github.com/cucumber/godog/blob/main/CONTRIBUTING.md [releasing guide]: https://github.com/cucumber/godog/blob/main/RELEASING.md [community Slack]: https://cucumber.io/community#slack + + + diff --git a/internal/formatters/fmt_cucumber.go b/internal/formatters/fmt_cucumber.go index 545e3fa0..765403ba 100644 --- a/internal/formatters/fmt_cucumber.go +++ b/internal/formatters/fmt_cucumber.go @@ -12,6 +12,7 @@ package formatters */ import ( + "encoding/base64" "encoding/json" "fmt" "io" @@ -139,14 +140,21 @@ type cukeMatch struct { Location string `json:"location"` } +type cukeEmbedding struct { + Name string `json:"name"` + MimeType string `json:"mime_type"` + Data string `json:"data"` +} + 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"` - DataTable []*cukeDataTableRow `json:"rows,omitempty"` + 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"` + DataTable []*cukeDataTableRow `json:"rows,omitempty"` + Embeddings []*cukeEmbedding `json:"embeddings,omitempty"` } type cukeDataTableRow struct { @@ -294,6 +302,21 @@ func (f *Cuke) buildCukeStep(pickle *messages.Pickle, stepResult models.PickleSt cukeStep.Match.Location = fmt.Sprintf("%s:%d", pickle.Uri, step.Location.Line) } + if stepResult.Attachments != nil { + attachments := []*cukeEmbedding{} + + for _, a := range stepResult.Attachments { + attachments = append(attachments, &cukeEmbedding{ + Name: a.Name, + Data: base64.RawStdEncoding.EncodeToString(a.Data), + MimeType: a.MimeType, + }) + } + + if len(attachments) > 0 { + cukeStep.Embeddings = attachments + } + } return cukeStep } diff --git a/internal/formatters/fmt_events.go b/internal/formatters/fmt_events.go index e264db57..4ed401cf 100644 --- a/internal/formatters/fmt_events.go +++ b/internal/formatters/fmt_events.go @@ -153,6 +153,31 @@ func (f *Events) step(pickle *messages.Pickle, pickleStep *messages.PickleStep) if pickleStepResult.Err != nil { errMsg = pickleStepResult.Err.Error() } + + if pickleStepResult.Attachments != nil { + for _, attachment := range pickleStepResult.Attachments { + + f.event(&struct { + Event string `json:"event"` + Location string `json:"location"` + Timestamp int64 `json:"timestamp"` + ContentEncoding string `json:"contentEncoding"` + FileName string `json:"fileName"` + MimeType string `json:"mimeType"` + Body string `json:"body"` + }{ + "Attachment", + fmt.Sprintf("%s:%d", pickle.Uri, step.Location.Line), + utils.TimeNowFunc().UnixNano() / nanoSec, + messages.AttachmentContentEncoding_BASE64.String(), + attachment.Name, + attachment.MimeType, + string(attachment.Data), + }) + + } + } + f.event(&struct { Event string `json:"event"` Location string `json:"location"` diff --git a/internal/formatters/fmt_output_test.go b/internal/formatters/fmt_output_test.go index 423558da..f9b4e668 100644 --- a/internal/formatters/fmt_output_test.go +++ b/internal/formatters/fmt_output_test.go @@ -2,11 +2,12 @@ package formatters_test import ( "bytes" + "context" "fmt" - "io/ioutil" "os" "path" "path/filepath" + "regexp" "strings" "testing" @@ -24,9 +25,7 @@ func Test_FmtOutput(t *testing.T) { featureFiles, err := listFmtOutputTestsFeatureFiles() require.Nil(t, err) - formatters := []string{"cucumber", "events", "junit", "pretty", "progress", "junit,pretty"} - for _, fmtName := range formatters { for _, featureFile := range featureFiles { testName := fmt.Sprintf("%s/%s", fmtName, featureFile) @@ -65,6 +64,7 @@ func fmtOutputTest(fmtName, testName, featureFilePath string) func(*testing.T) { ctx.Step(`^(?:a )?pending step$`, pendingStepDef) ctx.Step(`^(?:a )?passing step$`, passingStepDef) ctx.Step(`^odd (\d+) and even (\d+) number$`, oddEvenStepDef) + ctx.Step(`^(?:a )?a step with attachment$`, stepWithAttachment) } return func(t *testing.T) { @@ -74,7 +74,7 @@ func fmtOutputTest(fmtName, testName, featureFilePath string) func(*testing.T) { t.Skipf("Couldn't find expected output file %q", expectOutputPath) } - expectedOutput, err := ioutil.ReadFile(expectOutputPath) + expectedOutput, err := os.ReadFile(expectOutputPath) require.NoError(t, err) var buf bytes.Buffer @@ -92,12 +92,23 @@ func fmtOutputTest(fmtName, testName, featureFilePath string) func(*testing.T) { Options: &opts, }.Run() - expected := string(expectedOutput) - actual := buf.String() + // normalise on unix line ending so expected vs actual works cross platform + expected := normalise(string(expectedOutput)) + actual := normalise(buf.String()) assert.Equalf(t, expected, actual, "path: %s", expectOutputPath) } } +func normalise(s string) string { + + m := regexp.MustCompile("fmt_output_test.go:[0-9]+") + normalised := m.ReplaceAllString(s, "fmt_output_test.go:XXX") + normalised = strings.Replace(normalised, "\r\n", "\n", -1) + normalised = strings.Replace(normalised, "\\r\\n", "\\n", -1) + + return normalised +} + func passingStepDef() error { return nil } func oddEvenStepDef(odd, even int) error { return oddOrEven(odd, even) } @@ -115,3 +126,12 @@ func oddOrEven(odd, even int) error { func pendingStepDef() error { return godog.ErrPending } func failingStepDef() error { return fmt.Errorf("step failed") } + +func stepWithAttachment(ctx context.Context) (context.Context, error) { + ctxOut := godog.Attach(ctx, + godog.Attachment{Body: []byte("TheData1"), FileName: "TheFilename1", MediaType: "text/plain"}, + godog.Attachment{Body: []byte("TheData2"), FileName: "TheFilename2", MediaType: "text/plain"}, + ) + + return ctxOut, nil +} diff --git a/internal/formatters/formatter-tests/cucumber/scenario_with_attachment b/internal/formatters/formatter-tests/cucumber/scenario_with_attachment new file mode 100644 index 00000000..71e0ab82 --- /dev/null +++ b/internal/formatters/formatter-tests/cucumber/scenario_with_attachment @@ -0,0 +1,46 @@ +[ + { + "uri": "formatter-tests/features/scenario_with_attachment.feature", + "id": "scenario-with-attachment", + "keyword": "Feature", + "name": "scenario with attachment", + "description": " describes\n an attachment\n feature", + "line": 1, + "elements": [ + { + "id": "scenario-with-attachment;step-with-attachment", + "keyword": "Scenario", + "name": "step with attachment", + "description": "", + "line": 6, + "type": "scenario", + "steps": [ + { + "keyword": "Given ", + "name": "a step with attachment", + "line": 7, + "match": { + "location": "fmt_output_test.go:119" + }, + "result": { + "status": "passed", + "duration": 0 + }, + "embeddings": [ + { + "name": "TheFilename1", + "mime_type": "text/plain", + "data": "VGhlRGF0YTE" + }, + { + "name": "TheFilename2", + "mime_type": "text/plain", + "data": "VGhlRGF0YTI" + } + ] + } + ] + } + ] + } +] diff --git a/internal/formatters/formatter-tests/events/scenario_with_attachment b/internal/formatters/formatter-tests/events/scenario_with_attachment new file mode 100644 index 00000000..d803a76c --- /dev/null +++ b/internal/formatters/formatter-tests/events/scenario_with_attachment @@ -0,0 +1,10 @@ +{"event":"TestRunStarted","version":"0.1.0","timestamp":-6795364578871,"suite":"events"} +{"event":"TestSource","location":"formatter-tests/features/scenario_with_attachment.feature:1","source":"Feature: scenario with attachment\n describes\n an attachment\n feature\n\n Scenario: step with attachment\n Given a step with attachment\n"} +{"event":"TestCaseStarted","location":"formatter-tests/features/scenario_with_attachment.feature:6","timestamp":-6795364578871} +{"event":"StepDefinitionFound","location":"formatter-tests/features/scenario_with_attachment.feature:7","definition_id":"fmt_output_test.go:XXX -\u003e github.com/cucumber/godog/internal/formatters_test.stepWithAttachment","arguments":[]} +{"event":"TestStepStarted","location":"formatter-tests/features/scenario_with_attachment.feature:7","timestamp":-6795364578871} +{"event":"Attachment","location":"formatter-tests/features/scenario_with_attachment.feature:7","timestamp":-6795364578871,"contentEncoding":"BASE64","fileName":"TheFilename1","mimeType":"text/plain","body":"TheData1"} +{"event":"Attachment","location":"formatter-tests/features/scenario_with_attachment.feature:7","timestamp":-6795364578871,"contentEncoding":"BASE64","fileName":"TheFilename2","mimeType":"text/plain","body":"TheData2"} +{"event":"TestStepFinished","location":"formatter-tests/features/scenario_with_attachment.feature:7","timestamp":-6795364578871,"status":"passed"} +{"event":"TestCaseFinished","location":"formatter-tests/features/scenario_with_attachment.feature:6","timestamp":-6795364578871,"status":"passed"} +{"event":"TestRunFinished","status":"passed","timestamp":-6795364578871,"snippets":"","memory":""} diff --git a/internal/formatters/formatter-tests/features/scenario_with_attachment.feature b/internal/formatters/formatter-tests/features/scenario_with_attachment.feature new file mode 100644 index 00000000..0299cf35 --- /dev/null +++ b/internal/formatters/formatter-tests/features/scenario_with_attachment.feature @@ -0,0 +1,7 @@ +Feature: scenario with attachment + describes + an attachment + feature + + Scenario: step with attachment + Given a step with attachment diff --git a/internal/models/results.go b/internal/models/results.go index 64d32884..10abb18b 100644 --- a/internal/models/results.go +++ b/internal/models/results.go @@ -18,6 +18,13 @@ type PickleResult struct { StartedAt time.Time } +// PickleAttachment ... +type PickleAttachment struct { + Name string + MimeType string + Data []byte +} + // PickleStepResult ... type PickleStepResult struct { Status StepResultStatus @@ -28,6 +35,8 @@ type PickleStepResult struct { PickleStepID string Def *StepDefinition + + Attachments []*PickleAttachment } // NewStepResult ... @@ -35,6 +44,7 @@ func NewStepResult( status StepResultStatus, pickleID, pickleStepID string, match *StepDefinition, + attachments []*PickleAttachment, err error, ) PickleStepResult { return PickleStepResult{ @@ -44,6 +54,7 @@ func NewStepResult( PickleID: pickleID, PickleStepID: pickleStepID, Def: match, + Attachments: attachments, } } diff --git a/suite.go b/suite.go index fdfcb197..1cf1da3e 100644 --- a/suite.go +++ b/suite.go @@ -68,6 +68,42 @@ type suite struct { afterScenarioHandlers []AfterScenarioHook } +type Attachment struct { + Body []byte + FileName string + MediaType string +} + +type attachmentKey struct{} + +func Attach(ctx context.Context, attachments ...Attachment) context.Context { + return context.WithValue(ctx, attachmentKey{}, attachments) +} +func Attachments(ctx context.Context) []Attachment { + v := ctx.Value(attachmentKey{}) + + if v == nil { + return []Attachment{} + } + return v.([]Attachment) +} + +func pickleAttachments(ctx context.Context) []*models.PickleAttachment { + + pickledAttachments := []*models.PickleAttachment{} + attachments := Attachments(ctx) + + for _, a := range attachments { + pickledAttachments = append(pickledAttachments, &models.PickleAttachment{ + Name: a.FileName, + Data: a.Body, + MimeType: a.MediaType, + }) + } + + return pickledAttachments +} + func (s *suite) matchStep(step *messages.PickleStep) *models.StepDefinition { def := s.matchStepTextAndType(step.Text, step.Type) if def != nil && step.Argument != nil { @@ -124,6 +160,9 @@ func (s *suite) runStep(ctx context.Context, pickle *Scenario, step *Step, scena status = StepPassed } + pickledAttachments := pickleAttachments(ctx) + ctx = Attach(ctx) + // Run after step handlers. rctx, err = s.runAfterStepHooks(ctx, step, status, err) @@ -140,19 +179,19 @@ func (s *suite) runStep(ctx context.Context, pickle *Scenario, step *Step, scena switch { case err == nil: - sr := models.NewStepResult(models.Passed, pickle.Id, step.Id, match, nil) + sr := models.NewStepResult(models.Passed, pickle.Id, step.Id, match, pickledAttachments, nil) s.storage.MustInsertPickleStepResult(sr) s.fmt.Passed(pickle, step, match.GetInternalStepDefinition()) case errors.Is(err, ErrPending): - sr := models.NewStepResult(models.Pending, pickle.Id, step.Id, match, nil) + sr := models.NewStepResult(models.Pending, pickle.Id, step.Id, match, pickledAttachments, nil) s.storage.MustInsertPickleStepResult(sr) s.fmt.Pending(pickle, step, match.GetInternalStepDefinition()) case errors.Is(err, ErrSkip): - sr := models.NewStepResult(models.Skipped, pickle.Id, step.Id, match, nil) + sr := models.NewStepResult(models.Skipped, pickle.Id, step.Id, match, pickledAttachments, nil) s.storage.MustInsertPickleStepResult(sr) s.fmt.Skipped(pickle, step, match.GetInternalStepDefinition()) default: - sr := models.NewStepResult(models.Failed, pickle.Id, step.Id, match, err) + sr := models.NewStepResult(models.Failed, pickle.Id, step.Id, match, pickledAttachments, err) s.storage.MustInsertPickleStepResult(sr) s.fmt.Failed(pickle, step, match.GetInternalStepDefinition(), err) } @@ -171,7 +210,11 @@ func (s *suite) runStep(ctx context.Context, pickle *Scenario, step *Step, scena s.fmt.Defined(pickle, step, match.GetInternalStepDefinition()) if err != nil { - sr := models.NewStepResult(models.Failed, pickle.Id, step.Id, match, nil) + + pickledAttachments := pickleAttachments(ctx) + ctx = Attach(ctx) + + sr := models.NewStepResult(models.Failed, pickle.Id, step.Id, match, pickledAttachments, nil) s.storage.MustInsertPickleStepResult(sr) return ctx, err } @@ -193,7 +236,10 @@ func (s *suite) runStep(ctx context.Context, pickle *Scenario, step *Step, scena } } - sr := models.NewStepResult(models.Undefined, pickle.Id, step.Id, match, nil) + pickledAttachments := pickleAttachments(ctx) + ctx = Attach(ctx) + + sr := models.NewStepResult(models.Undefined, pickle.Id, step.Id, match, pickledAttachments, nil) s.storage.MustInsertPickleStepResult(sr) s.fmt.Undefined(pickle, step, match.GetInternalStepDefinition()) @@ -201,7 +247,10 @@ func (s *suite) runStep(ctx context.Context, pickle *Scenario, step *Step, scena } if scenarioErr != nil { - sr := models.NewStepResult(models.Skipped, pickle.Id, step.Id, match, nil) + pickledAttachments := pickleAttachments(ctx) + ctx = Attach(ctx) + + sr := models.NewStepResult(models.Skipped, pickle.Id, step.Id, match, pickledAttachments, nil) s.storage.MustInsertPickleStepResult(sr) s.fmt.Skipped(pickle, step, match.GetInternalStepDefinition())