| package views |
| |
| import ( |
| "encoding/xml" |
| "fmt" |
| "io/ioutil" |
| "sort" |
| "strings" |
| |
| "github.com/hashicorp/terraform/internal/command/arguments" |
| "github.com/hashicorp/terraform/internal/command/format" |
| "github.com/hashicorp/terraform/internal/moduletest" |
| "github.com/hashicorp/terraform/internal/terminal" |
| "github.com/hashicorp/terraform/internal/tfdiags" |
| "github.com/mitchellh/colorstring" |
| ) |
| |
| // Test is the view interface for the "terraform test" command. |
| type Test interface { |
| // Results presents the given test results. |
| Results(map[string]*moduletest.Suite) tfdiags.Diagnostics |
| |
| // Diagnostics is for reporting warnings or errors that occurred with the |
| // mechanics of running tests. For this command in particular, some |
| // errors are considered to be test failures rather than mechanism failures, |
| // and so those will be reported via Results rather than via Diagnostics. |
| Diagnostics(tfdiags.Diagnostics) |
| } |
| |
| // NewTest returns an implementation of Test configured to respect the |
| // settings described in the given arguments. |
| func NewTest(base *View, args arguments.TestOutput) Test { |
| return &testHuman{ |
| streams: base.streams, |
| showDiagnostics: base.Diagnostics, |
| colorize: base.colorize, |
| junitXMLFile: args.JUnitXMLFile, |
| } |
| } |
| |
| type testHuman struct { |
| // This is the subset of functionality we need from the base view. |
| streams *terminal.Streams |
| showDiagnostics func(diags tfdiags.Diagnostics) |
| colorize *colorstring.Colorize |
| |
| // If junitXMLFile is not empty then results will be written to |
| // the given file path in addition to the usual output. |
| junitXMLFile string |
| } |
| |
| func (v *testHuman) Results(results map[string]*moduletest.Suite) tfdiags.Diagnostics { |
| var diags tfdiags.Diagnostics |
| |
| // FIXME: Due to how this prototype command evolved concurrently with |
| // establishing the idea of command views, the handling of JUnit output |
| // as part of the "human" view rather than as a separate view in its |
| // own right is a little odd and awkward. We should refactor this |
| // prior to making "terraform test" a real supported command to make |
| // it be structured more like the other commands that use the views |
| // package. |
| |
| v.humanResults(results) |
| |
| if v.junitXMLFile != "" { |
| moreDiags := v.junitXMLResults(results, v.junitXMLFile) |
| diags = diags.Append(moreDiags) |
| } |
| |
| return diags |
| } |
| |
| func (v *testHuman) Diagnostics(diags tfdiags.Diagnostics) { |
| if len(diags) == 0 { |
| return |
| } |
| v.showDiagnostics(diags) |
| } |
| |
| func (v *testHuman) humanResults(results map[string]*moduletest.Suite) { |
| failCount := 0 |
| width := v.streams.Stderr.Columns() |
| |
| suiteNames := make([]string, 0, len(results)) |
| for suiteName := range results { |
| suiteNames = append(suiteNames, suiteName) |
| } |
| sort.Strings(suiteNames) |
| for _, suiteName := range suiteNames { |
| suite := results[suiteName] |
| |
| componentNames := make([]string, 0, len(suite.Components)) |
| for componentName := range suite.Components { |
| componentNames = append(componentNames, componentName) |
| } |
| for _, componentName := range componentNames { |
| component := suite.Components[componentName] |
| |
| assertionNames := make([]string, 0, len(component.Assertions)) |
| for assertionName := range component.Assertions { |
| assertionNames = append(assertionNames, assertionName) |
| } |
| sort.Strings(assertionNames) |
| |
| for _, assertionName := range assertionNames { |
| assertion := component.Assertions[assertionName] |
| |
| fullName := fmt.Sprintf("%s.%s.%s", suiteName, componentName, assertionName) |
| if strings.HasPrefix(componentName, "(") { |
| // parenthesis-prefixed components are placeholders that |
| // the test harness generates to represent problems that |
| // prevented checking any assertions at all, so we'll |
| // just hide them and show the suite name. |
| fullName = suiteName |
| } |
| headingExtra := fmt.Sprintf("%s (%s)", fullName, assertion.Description) |
| |
| switch assertion.Outcome { |
| case moduletest.Failed: |
| // Failed means that the assertion was successfully |
| // excecuted but that the assertion condition didn't hold. |
| v.eprintRuleHeading("yellow", "Failed", headingExtra) |
| |
| case moduletest.Error: |
| // Error means that the system encountered an unexpected |
| // error when trying to evaluate the assertion. |
| v.eprintRuleHeading("red", "Error", headingExtra) |
| |
| default: |
| // We don't do anything for moduletest.Passed or |
| // moduletest.Skipped. Perhaps in future we'll offer a |
| // -verbose option to include information about those. |
| continue |
| } |
| failCount++ |
| |
| if len(assertion.Message) > 0 { |
| dispMsg := format.WordWrap(assertion.Message, width) |
| v.streams.Eprintln(dispMsg) |
| } |
| if len(assertion.Diagnostics) > 0 { |
| // We'll do our own writing of the diagnostics in this |
| // case, rather than using v.Diagnostics, because we |
| // specifically want all of these diagnostics to go to |
| // Stderr along with all of the other output we've |
| // generated. |
| for _, diag := range assertion.Diagnostics { |
| diagStr := format.Diagnostic(diag, nil, v.colorize, width) |
| v.streams.Eprint(diagStr) |
| } |
| } |
| } |
| } |
| } |
| |
| if failCount > 0 { |
| // If we've printed at least one failure then we'll have printed at |
| // least one horizontal rule across the terminal, and so we'll balance |
| // that with another horizontal rule. |
| if width > 1 { |
| rule := strings.Repeat("─", width-1) |
| v.streams.Eprintln(v.colorize.Color("[dark_gray]" + rule)) |
| } |
| } |
| |
| if failCount == 0 { |
| if len(results) > 0 { |
| // This is not actually an error, but it's convenient if all of our |
| // result output goes to the same stream for when this is running in |
| // automation that might be gathering this output via a pipe. |
| v.streams.Eprint(v.colorize.Color("[bold][green]Success![reset] All of the test assertions passed.\n\n")) |
| } else { |
| v.streams.Eprint(v.colorize.Color("[bold][yellow]No tests defined.[reset] This module doesn't have any test suites to run.\n\n")) |
| } |
| } |
| |
| // Try to flush any buffering that might be happening. (This isn't always |
| // successful, depending on what sort of fd Stderr is connected to.) |
| v.streams.Stderr.File.Sync() |
| } |
| |
| func (v *testHuman) junitXMLResults(results map[string]*moduletest.Suite, filename string) tfdiags.Diagnostics { |
| var diags tfdiags.Diagnostics |
| |
| // "JUnit XML" is a file format that has become a de-facto standard for |
| // test reporting tools but that is not formally specified anywhere, and |
| // so each producer and consumer implementation unfortunately tends to |
| // differ in certain ways from others. |
| // With that in mind, this is a best effort sort of thing aimed at being |
| // broadly compatible with various consumers, but it's likely that |
| // some consumers will present these results better than others. |
| // This implementation is based mainly on the pseudo-specification of the |
| // format curated here, based on the Jenkins parser implementation: |
| // https://llg.cubic.org/docs/junit/ |
| |
| // An "Outcome" represents one of the various XML elements allowed inside |
| // a testcase element to indicate the test outcome. |
| type Outcome struct { |
| Message string `xml:"message,omitempty"` |
| } |
| |
| // TestCase represents an individual test case as part of a suite. Note |
| // that a JUnit XML incorporates both the "component" and "assertion" |
| // levels of our model: we pretend that component is a class name and |
| // assertion is a method name in order to match with the Java-flavored |
| // expectations of JUnit XML, which are hopefully close enough to get |
| // a test result rendering that's useful to humans. |
| type TestCase struct { |
| AssertionName string `xml:"name"` |
| ComponentName string `xml:"classname"` |
| |
| // These fields represent the different outcomes of a TestCase. Only one |
| // of these should be populated in each TestCase; this awkward |
| // structure is just to make this play nicely with encoding/xml's |
| // expecatations. |
| Skipped *Outcome `xml:"skipped,omitempty"` |
| Error *Outcome `xml:"error,omitempty"` |
| Failure *Outcome `xml:"failure,omitempty"` |
| |
| Stderr string `xml:"system-out,omitempty"` |
| } |
| |
| // TestSuite represents an individual test suite, of potentially many |
| // in a JUnit XML document. |
| type TestSuite struct { |
| Name string `xml:"name"` |
| TotalCount int `xml:"tests"` |
| SkippedCount int `xml:"skipped"` |
| ErrorCount int `xml:"errors"` |
| FailureCount int `xml:"failures"` |
| Cases []*TestCase `xml:"testcase"` |
| } |
| |
| // TestSuites represents the root element of the XML document. |
| type TestSuites struct { |
| XMLName struct{} `xml:"testsuites"` |
| ErrorCount int `xml:"errors"` |
| FailureCount int `xml:"failures"` |
| TotalCount int `xml:"tests"` |
| Suites []*TestSuite `xml:"testsuite"` |
| } |
| |
| xmlSuites := TestSuites{} |
| suiteNames := make([]string, 0, len(results)) |
| for suiteName := range results { |
| suiteNames = append(suiteNames, suiteName) |
| } |
| sort.Strings(suiteNames) |
| for _, suiteName := range suiteNames { |
| suite := results[suiteName] |
| |
| xmlSuite := &TestSuite{ |
| Name: suiteName, |
| } |
| xmlSuites.Suites = append(xmlSuites.Suites, xmlSuite) |
| |
| componentNames := make([]string, 0, len(suite.Components)) |
| for componentName := range suite.Components { |
| componentNames = append(componentNames, componentName) |
| } |
| for _, componentName := range componentNames { |
| component := suite.Components[componentName] |
| |
| assertionNames := make([]string, 0, len(component.Assertions)) |
| for assertionName := range component.Assertions { |
| assertionNames = append(assertionNames, assertionName) |
| } |
| sort.Strings(assertionNames) |
| |
| for _, assertionName := range assertionNames { |
| assertion := component.Assertions[assertionName] |
| xmlSuites.TotalCount++ |
| xmlSuite.TotalCount++ |
| |
| xmlCase := &TestCase{ |
| ComponentName: componentName, |
| AssertionName: assertionName, |
| } |
| xmlSuite.Cases = append(xmlSuite.Cases, xmlCase) |
| |
| switch assertion.Outcome { |
| case moduletest.Pending: |
| // We represent "pending" cases -- cases blocked by |
| // upstream errors -- as if they were "skipped" in JUnit |
| // terms, because we didn't actually check them and so |
| // can't say whether they succeeded or not. |
| xmlSuite.SkippedCount++ |
| xmlCase.Skipped = &Outcome{ |
| Message: assertion.Message, |
| } |
| case moduletest.Failed: |
| xmlSuites.FailureCount++ |
| xmlSuite.FailureCount++ |
| xmlCase.Failure = &Outcome{ |
| Message: assertion.Message, |
| } |
| case moduletest.Error: |
| xmlSuites.ErrorCount++ |
| xmlSuite.ErrorCount++ |
| xmlCase.Error = &Outcome{ |
| Message: assertion.Message, |
| } |
| |
| // We'll also include the diagnostics in the "stderr" |
| // portion of the output, so they'll hopefully be visible |
| // in a test log viewer in JUnit-XML-Consuming CI systems. |
| var buf strings.Builder |
| for _, diag := range assertion.Diagnostics { |
| diagStr := format.DiagnosticPlain(diag, nil, 68) |
| buf.WriteString(diagStr) |
| } |
| xmlCase.Stderr = buf.String() |
| } |
| |
| } |
| } |
| } |
| |
| xmlOut, err := xml.MarshalIndent(&xmlSuites, "", " ") |
| if err != nil { |
| // If marshalling fails then that's a bug in the code above, |
| // because we should always be producing a value that is |
| // accepted by encoding/xml. |
| panic(fmt.Sprintf("invalid values to marshal as JUnit XML: %s", err)) |
| } |
| |
| err = ioutil.WriteFile(filename, xmlOut, 0644) |
| if err != nil { |
| diags = diags.Append(tfdiags.Sourceless( |
| tfdiags.Error, |
| "Failed to write JUnit XML file", |
| fmt.Sprintf( |
| "Could not create %s to record the test results in JUnit XML format: %s.", |
| filename, |
| err, |
| ), |
| )) |
| } |
| |
| return diags |
| } |
| |
| func (v *testHuman) eprintRuleHeading(color, prefix, extra string) { |
| const lineCell string = "─" |
| textLen := len(prefix) + len(": ") + len(extra) |
| spacingLen := 2 |
| leftLineLen := 3 |
| |
| rightLineLen := 0 |
| width := v.streams.Stderr.Columns() |
| if (textLen + spacingLen + leftLineLen) < (width - 1) { |
| // (we allow an extra column at the end because some terminals can't |
| // print in the final column without wrapping to the next line) |
| rightLineLen = width - (textLen + spacingLen + leftLineLen) - 1 |
| } |
| |
| colorCode := "[" + color + "]" |
| |
| // We'll prepare what we're going to print in memory first, so that we can |
| // send it all to stderr in one write in case other programs are also |
| // concurrently trying to write to the terminal for some reason. |
| var buf strings.Builder |
| buf.WriteString(v.colorize.Color(colorCode + strings.Repeat(lineCell, leftLineLen))) |
| buf.WriteByte(' ') |
| buf.WriteString(v.colorize.Color("[bold]" + colorCode + prefix + ":")) |
| buf.WriteByte(' ') |
| buf.WriteString(extra) |
| if rightLineLen > 0 { |
| buf.WriteByte(' ') |
| buf.WriteString(v.colorize.Color(colorCode + strings.Repeat(lineCell, rightLineLen))) |
| } |
| v.streams.Eprintln(buf.String()) |
| } |