hcl/cmd/hclspecsuite/runner.go
Martin Atkins ef5c50bb09 hcl/spectests: run the spec testsuite as part of "go test"
Although the spec testsuite and associated harness is designed to be
usable by other implementations of HCL not written in Go, it's convenient
to run it as part of our own "go test" test suite here so there isn't
an additional thing to run on each change.

To achieve this, the new package hcl/spectests will build both hcldec and
hclspecsuite from latest source and then run the latter to execute the
test suite, capturing the output and converting it (sloppily) into
testing.T method calls to produce something vaguely reasonable.

Other than the small amount of "parsing" to make it look in the output
like a normal Go test, there's nothing special going on here and so it's
still valid to run the spec suite manually with a build of hcldec from
this codebase, which should produce the same result.
2018-08-12 18:22:10 -07:00

522 lines
14 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
ctyjson "github.com/zclconf/go-cty/cty/json"
"github.com/hashicorp/hcl2/ext/typeexpr"
"github.com/hashicorp/hcl2/hcl"
"github.com/hashicorp/hcl2/hclparse"
)
type Runner struct {
parser *hclparse.Parser
hcldecPath string
baseDir string
logBegin LogBeginCallback
logProblems LogProblemsCallback
}
func (r *Runner) Run() hcl.Diagnostics {
return r.runDir(r.baseDir)
}
func (r *Runner) runDir(dir string) hcl.Diagnostics {
var diags hcl.Diagnostics
infos, err := ioutil.ReadDir(dir)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to read test directory",
Detail: fmt.Sprintf("The directory %q could not be opened: %s.", dir, err),
})
return diags
}
var tests []string
var subDirs []string
for _, info := range infos {
name := info.Name()
if strings.HasPrefix(name, ".") {
continue
}
if info.IsDir() {
subDirs = append(subDirs, name)
}
if strings.HasSuffix(name, ".t") {
tests = append(tests, name)
}
}
sort.Strings(tests)
sort.Strings(subDirs)
for _, filename := range tests {
filename = filepath.Join(dir, filename)
testDiags := r.runTest(filename)
diags = append(diags, testDiags...)
}
for _, dirName := range subDirs {
dir := filepath.Join(dir, dirName)
dirDiags := r.runDir(dir)
diags = append(diags, dirDiags...)
}
return diags
}
func (r *Runner) runTest(filename string) hcl.Diagnostics {
prettyName := r.prettyTestName(filename)
tf, diags := r.LoadTestFile(filename)
if diags.HasErrors() {
// We'll still log, so it's clearer which test the diagnostics belong to.
if r.logBegin != nil {
r.logBegin(prettyName, nil)
}
if r.logProblems != nil {
r.logProblems(prettyName, nil, diags)
return nil // don't duplicate the diagnostics we already reported
}
return diags
}
if r.logBegin != nil {
r.logBegin(prettyName, tf)
}
basePath := filename[:len(filename)-2]
specFilename := basePath + ".hcldec"
nativeFilename := basePath + ".hcl"
jsonFilename := basePath + ".hcl.json"
// We'll add the source code of the spec file to our own parser, even
// though it'll actually be parsed by the hcldec child process, since that
// way we can produce nice diagnostic messages if hcldec fails to process
// the spec file.
src, err := ioutil.ReadFile(specFilename)
if err == nil {
r.parser.AddFile(specFilename, &hcl.File{
Bytes: src,
})
}
if _, err := os.Stat(specFilename); err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing .hcldec file",
Detail: fmt.Sprintf("No specification file for test %s: %s.", prettyName, err),
})
return diags
}
if _, err := os.Stat(nativeFilename); err == nil {
moreDiags := r.runTestInput(specFilename, nativeFilename, tf)
diags = append(diags, moreDiags...)
}
if _, err := os.Stat(jsonFilename); err == nil {
moreDiags := r.runTestInput(specFilename, jsonFilename, tf)
diags = append(diags, moreDiags...)
}
if r.logProblems != nil {
r.logProblems(prettyName, nil, diags)
return nil // don't duplicate the diagnostics we already reported
}
return diags
}
func (r *Runner) runTestInput(specFilename, inputFilename string, tf *TestFile) hcl.Diagnostics {
// We'll add the source code of the input file to our own parser, even
// though it'll actually be parsed by the hcldec child process, since that
// way we can produce nice diagnostic messages if hcldec fails to process
// the input file.
src, err := ioutil.ReadFile(inputFilename)
if err == nil {
r.parser.AddFile(inputFilename, &hcl.File{
Bytes: src,
})
}
var diags hcl.Diagnostics
if tf.ChecksTraversals {
gotTraversals, moreDiags := r.hcldecVariables(specFilename, inputFilename)
diags = append(diags, moreDiags...)
if !moreDiags.HasErrors() {
expected := tf.ExpectedTraversals
for _, got := range gotTraversals {
e := findTraversalSpec(got, expected)
rng := got.SourceRange()
if e == nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Unexpected traversal",
Detail: "Detected traversal that is not indicated as expected in the test file.",
Subject: &rng,
})
} else {
moreDiags := checkTraversalsMatch(got, inputFilename, e)
diags = append(diags, moreDiags...)
}
}
// Look for any traversals that didn't show up at all.
for _, e := range expected {
if t := findTraversalForSpec(e, gotTraversals); t == nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing expected traversal",
Detail: "This expected traversal was not detected.",
Subject: e.Traversal.SourceRange().Ptr(),
})
}
}
}
}
val, transformDiags := r.hcldecTransform(specFilename, inputFilename)
if len(tf.ExpectedDiags) == 0 {
diags = append(diags, transformDiags...)
if transformDiags.HasErrors() {
// If hcldec failed then there's no point in continuing.
return diags
}
if errs := val.Type().TestConformance(tf.ResultType); len(errs) > 0 {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Incorrect result type",
Detail: fmt.Sprintf(
"Input file %s produced %s, but was expecting %s.",
inputFilename, typeexpr.TypeString(val.Type()), typeexpr.TypeString(tf.ResultType),
),
})
}
if tf.Result != cty.NilVal {
cmpVal, err := convert.Convert(tf.Result, tf.ResultType)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Incorrect type for result value",
Detail: fmt.Sprintf(
"Result does not conform to the given result type: %s.", err,
),
Subject: &tf.ResultRange,
})
} else {
if !val.RawEquals(cmpVal) {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Incorrect result value",
Detail: fmt.Sprintf(
"Input file %s produced %#v, but was expecting %#v.",
inputFilename, val, tf.Result,
),
})
}
}
}
} else {
// We're expecting diagnostics, and so we'll need to correlate the
// severities and source ranges of our actual diagnostics against
// what we were expecting.
type DiagnosticEntry struct {
Severity hcl.DiagnosticSeverity
Range hcl.Range
}
got := make(map[DiagnosticEntry]*hcl.Diagnostic)
want := make(map[DiagnosticEntry]hcl.Range)
for _, diag := range transformDiags {
if diag.Subject == nil {
// Sourceless diagnostics can never be expected, so we'll just
// pass these through as-is and assume they are hcldec
// operational errors.
diags = append(diags, diag)
continue
}
if diag.Subject.Filename != inputFilename {
// If the problem is for something other than the input file
// then it can't be expected.
diags = append(diags, diag)
continue
}
entry := DiagnosticEntry{
Severity: diag.Severity,
Range: *diag.Subject,
}
got[entry] = diag
}
for _, e := range tf.ExpectedDiags {
e.Range.Filename = inputFilename // assumed here, since we don't allow any other filename to be expected
entry := DiagnosticEntry{
Severity: e.Severity,
Range: e.Range,
}
want[entry] = e.DeclRange
}
for gotEntry, diag := range got {
if _, wanted := want[gotEntry]; !wanted {
// Pass through the diagnostic itself so the user can see what happened
diags = append(diags, diag)
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Unexpected diagnostic",
Detail: fmt.Sprintf(
"No %s diagnostic was expected %s. The unexpected diagnostic was shown above.",
severityString(gotEntry.Severity), rangeString(gotEntry.Range),
),
Subject: &gotEntry.Range,
})
}
}
for wantEntry, declRange := range want {
if _, gotted := got[wantEntry]; !gotted {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing expected diagnostic",
Detail: fmt.Sprintf(
"No %s diagnostic was generated %s.",
severityString(wantEntry.Severity), rangeString(wantEntry.Range),
),
Subject: &declRange,
})
}
}
}
return diags
}
func (r *Runner) hcldecTransform(specFile, inputFile string) (cty.Value, hcl.Diagnostics) {
var diags hcl.Diagnostics
var outBuffer bytes.Buffer
var errBuffer bytes.Buffer
cmd := &exec.Cmd{
Path: r.hcldecPath,
Args: []string{
r.hcldecPath,
"--spec=" + specFile,
"--diags=json",
"--with-type",
inputFile,
},
Stdout: &outBuffer,
Stderr: &errBuffer,
}
err := cmd.Run()
if err != nil {
if _, isExit := err.(*exec.ExitError); !isExit {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to run hcldec",
Detail: fmt.Sprintf("Sub-program hcldec failed to start: %s.", err),
})
return cty.DynamicVal, diags
}
// If we exited unsuccessfully then we'll expect diagnostics on stderr
moreDiags := decodeJSONDiagnostics(errBuffer.Bytes())
diags = append(diags, moreDiags...)
return cty.DynamicVal, diags
} else {
// Otherwise, we expect a JSON result value on stdout. Since we used
// --with-type above, we can decode as DynamicPseudoType to recover
// exactly the type that was saved, without the usual JSON lossiness.
val, err := ctyjson.Unmarshal(outBuffer.Bytes(), cty.DynamicPseudoType)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to parse hcldec result",
Detail: fmt.Sprintf("Sub-program hcldec produced an invalid result: %s.", err),
})
return cty.DynamicVal, diags
}
return val, diags
}
}
func (r *Runner) hcldecVariables(specFile, inputFile string) ([]hcl.Traversal, hcl.Diagnostics) {
var diags hcl.Diagnostics
var outBuffer bytes.Buffer
var errBuffer bytes.Buffer
cmd := &exec.Cmd{
Path: r.hcldecPath,
Args: []string{
r.hcldecPath,
"--spec=" + specFile,
"--diags=json",
"--var-refs",
inputFile,
},
Stdout: &outBuffer,
Stderr: &errBuffer,
}
err := cmd.Run()
if err != nil {
if _, isExit := err.(*exec.ExitError); !isExit {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to run hcldec",
Detail: fmt.Sprintf("Sub-program hcldec (evaluating input) failed to start: %s.", err),
})
return nil, diags
}
// If we exited unsuccessfully then we'll expect diagnostics on stderr
moreDiags := decodeJSONDiagnostics(errBuffer.Bytes())
diags = append(diags, moreDiags...)
return nil, diags
} else {
// Otherwise, we expect a JSON description of the traversals on stdout.
type PosJSON struct {
Line int `json:"line"`
Column int `json:"column"`
Byte int `json:"byte"`
}
type RangeJSON struct {
Filename string `json:"filename"`
Start PosJSON `json:"start"`
End PosJSON `json:"end"`
}
type StepJSON struct {
Kind string `json:"kind"`
Name string `json:"name,omitempty"`
Key json.RawMessage `json:"key,omitempty"`
Range RangeJSON `json:"range"`
}
type TraversalJSON struct {
Steps []StepJSON `json:"steps"`
}
var raw []TraversalJSON
err := json.Unmarshal(outBuffer.Bytes(), &raw)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to parse hcldec result",
Detail: fmt.Sprintf("Sub-program hcldec (with --var-refs) produced an invalid result: %s.", err),
})
return nil, diags
}
var ret []hcl.Traversal
if len(raw) == 0 {
return ret, diags
}
ret = make([]hcl.Traversal, 0, len(raw))
for _, rawT := range raw {
traversal := make(hcl.Traversal, 0, len(rawT.Steps))
for _, rawS := range rawT.Steps {
rng := hcl.Range{
Filename: rawS.Range.Filename,
Start: hcl.Pos{
Line: rawS.Range.Start.Line,
Column: rawS.Range.Start.Column,
Byte: rawS.Range.Start.Byte,
},
End: hcl.Pos{
Line: rawS.Range.End.Line,
Column: rawS.Range.End.Column,
Byte: rawS.Range.End.Byte,
},
}
switch rawS.Kind {
case "root":
traversal = append(traversal, hcl.TraverseRoot{
Name: rawS.Name,
SrcRange: rng,
})
case "attr":
traversal = append(traversal, hcl.TraverseAttr{
Name: rawS.Name,
SrcRange: rng,
})
case "index":
ty, err := ctyjson.ImpliedType([]byte(rawS.Key))
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to parse hcldec result",
Detail: fmt.Sprintf("Sub-program hcldec (with --var-refs) produced an invalid result: traversal step has invalid index key %s.", rawS.Key),
})
return nil, diags
}
keyVal, err := ctyjson.Unmarshal([]byte(rawS.Key), ty)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to parse hcldec result",
Detail: fmt.Sprintf("Sub-program hcldec (with --var-refs) produced a result with an invalid index key %s: %s.", rawS.Key, err),
})
return nil, diags
}
traversal = append(traversal, hcl.TraverseIndex{
Key: keyVal,
SrcRange: rng,
})
default:
// Should never happen since the above cases are exhaustive,
// but we'll catch it gracefully since this is coming from
// a possibly-buggy hcldec implementation that we're testing.
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to parse hcldec result",
Detail: fmt.Sprintf("Sub-program hcldec (with --var-refs) produced an invalid result: traversal step of unsupported kind %q.", rawS.Kind),
})
return nil, diags
}
}
ret = append(ret, traversal)
}
return ret, diags
}
}
func (r *Runner) prettyDirName(dir string) string {
rel, err := filepath.Rel(r.baseDir, dir)
if err != nil {
return filepath.ToSlash(dir)
}
return filepath.ToSlash(rel)
}
func (r *Runner) prettyTestName(filename string) string {
dir := filepath.Dir(filename)
dirName := r.prettyDirName(dir)
filename = filepath.Base(filename)
testName := filename[:len(filename)-2]
if dirName == "." {
return testName
}
return fmt.Sprintf("%s/%s", dirName, testName)
}