mirror of
https://github.com/google/go-jsonnet.git
synced 2025-08-06 14:27:30 +02:00
520 lines
14 KiB
Go
520 lines
14 KiB
Go
/*
|
|
Copyright 2016 Google Inc. All rights reserved.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package jsonnet
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/go-jsonnet/ast"
|
|
"github.com/google/go-jsonnet/internal/parser"
|
|
"github.com/google/go-jsonnet/internal/testutils"
|
|
)
|
|
|
|
var update = flag.Bool("update", false, "update .golden files")
|
|
var jsonnetCmd = flag.String("cmd", "", "path to jsonnet command (if not specified or empty, internal implementation is used)")
|
|
|
|
// TODO(sbarzowski) figure out how to measure coverage on the external tests
|
|
|
|
type testMetadata struct {
|
|
extVars map[string]string
|
|
extCode map[string]string
|
|
}
|
|
|
|
var standardExtVars = map[string]string{
|
|
"stringVar": "2 + 2",
|
|
}
|
|
|
|
var standardExtCode = map[string]string{
|
|
"codeVar": "3 + 3",
|
|
"errorVar": "error 'xxx'",
|
|
"staticErrorVar": ")",
|
|
"UndeclaredX": "x",
|
|
"selfRecursiveVar": `[42, std.extVar("selfRecursiveVar")[0] + 1]`,
|
|
"mutuallyRecursiveVar1": `[42, std.extVar("mutuallyRecursiveVar2")[0] + 1]`,
|
|
"mutuallyRecursiveVar2": `[42, std.extVar("mutuallyRecursiveVar1")[0] + 1]`,
|
|
}
|
|
|
|
var metadataForTests = map[string]testMetadata{
|
|
"testdata/extvar_code": {extVars: standardExtVars, extCode: standardExtCode},
|
|
"testdata/extvar_error": {extVars: standardExtVars, extCode: standardExtCode},
|
|
"testdata/extvar_hermetic": {extVars: standardExtVars, extCode: standardExtCode},
|
|
"testdata/extvar_mutually_recursive": {extVars: standardExtVars, extCode: standardExtCode},
|
|
"testdata/extvar_self_recursive": {extVars: standardExtVars, extCode: standardExtCode},
|
|
"testdata/extvar_static_error": {extVars: standardExtVars, extCode: standardExtCode},
|
|
"testdata/extvar_string": {extVars: standardExtVars, extCode: standardExtCode},
|
|
}
|
|
|
|
type mainTest struct {
|
|
name string
|
|
input string
|
|
golden string
|
|
meta *testMetadata
|
|
}
|
|
|
|
var jsonToString = &NativeFunction{
|
|
Name: "jsonToString",
|
|
Params: ast.Identifiers{"x"},
|
|
Func: func(x []interface{}) (interface{}, error) {
|
|
bytes, err := json.Marshal(x[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return string(bytes), nil
|
|
},
|
|
}
|
|
|
|
var nativeError = &NativeFunction{
|
|
Name: "nativeError",
|
|
Params: ast.Identifiers{},
|
|
Func: func(x []interface{}) (interface{}, error) {
|
|
return nil, errors.New("native function error")
|
|
},
|
|
}
|
|
|
|
var nativePanic = &NativeFunction{
|
|
Name: "nativePanic",
|
|
Params: ast.Identifiers{},
|
|
Func: func(x []interface{}) (interface{}, error) {
|
|
panic("native function panic")
|
|
},
|
|
}
|
|
|
|
type jsonnetInput struct {
|
|
name string
|
|
input []byte
|
|
eKind evalKind
|
|
stringOutputMode bool
|
|
extVars map[string]string
|
|
extCode map[string]string
|
|
}
|
|
|
|
type jsonnetResult struct {
|
|
// One of output or outputMulti is populated.
|
|
// If isError is set, the error is stored in output.
|
|
output string
|
|
outputMulti map[string]string
|
|
|
|
isError bool
|
|
}
|
|
|
|
func testChildren(node ast.Node) {
|
|
// Test that Children works on every node in the tree
|
|
for _, child := range parser.Children(node) {
|
|
testChildren(child)
|
|
}
|
|
// TODO(sbarzowski) it would be great to check somehow that all nodes were reached
|
|
}
|
|
|
|
func runInternalJsonnet(i jsonnetInput) jsonnetResult {
|
|
vm := MakeVM()
|
|
errFormatter := termErrorFormatter{pretty: true, maxStackTraceSize: 9}
|
|
|
|
vm.StringOutput = i.stringOutputMode
|
|
for name, value := range i.extVars {
|
|
vm.ExtVar(name, value)
|
|
}
|
|
for name, value := range i.extCode {
|
|
vm.ExtCode(name, value)
|
|
}
|
|
|
|
vm.NativeFunction(jsonToString)
|
|
vm.NativeFunction(nativeError)
|
|
vm.NativeFunction(nativePanic)
|
|
|
|
rawAST, _, staticErr := parser.SnippetToRawAST(ast.DiagnosticFileName(i.name), "", string(i.input))
|
|
if staticErr != nil {
|
|
return jsonnetResult{
|
|
output: errFormatter.Format(staticErr) + "\n",
|
|
isError: true,
|
|
}
|
|
}
|
|
testChildren(rawAST)
|
|
|
|
desugaredAST, err := SnippetToAST(i.name, string(i.input))
|
|
if err != nil {
|
|
return jsonnetResult{
|
|
output: errFormatter.Format(err) + "\n",
|
|
isError: true,
|
|
}
|
|
}
|
|
testChildren(desugaredAST)
|
|
|
|
// TODO(sbarzowski) We should treat the tests as anonymous snippets or import them with an importer.
|
|
rawOutput, err := vm.evaluateSnippet(ast.DiagnosticFileName(i.name), i.name, string(i.input), i.eKind)
|
|
switch {
|
|
case err != nil:
|
|
// TODO(sbarzowski) perhaps somehow mark that we are processing
|
|
// an error. But for now we can treat them the same.
|
|
return jsonnetResult{
|
|
output: errFormatter.Format(err) + "\n",
|
|
isError: true,
|
|
}
|
|
case i.eKind == evalKindMulti:
|
|
return jsonnetResult{
|
|
outputMulti: rawOutput.(map[string]string),
|
|
}
|
|
default:
|
|
return jsonnetResult{
|
|
output: rawOutput.(string),
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO(lukegb) CLI test support is presently completely broken: fix?
|
|
func runJsonnetCommand(i jsonnetInput) jsonnetResult {
|
|
// TODO(sbarzowski) Special handling of errors (which may differ between versions)
|
|
if i.eKind != evalKindRegular {
|
|
panic(fmt.Sprintf("eKind must be evalKindRegular for jsonnet CLI testing; was %v", i.eKind))
|
|
}
|
|
input := bytes.NewBuffer(i.input)
|
|
var output bytes.Buffer
|
|
isError := false
|
|
cmd := exec.Cmd{
|
|
Path: *jsonnetCmd,
|
|
Stdin: input,
|
|
Stdout: &output,
|
|
Stderr: &output,
|
|
Args: []string{"jsonnet", "-"},
|
|
}
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
switch err := err.(type) {
|
|
case *exec.ExitError:
|
|
// It finished with non-zero exit code
|
|
isError = true
|
|
default:
|
|
// We weren't able to run it
|
|
panic(err)
|
|
}
|
|
}
|
|
return jsonnetResult{
|
|
output: output.String(),
|
|
isError: isError,
|
|
}
|
|
}
|
|
|
|
func runJsonnet(i jsonnetInput) jsonnetResult {
|
|
if jsonnetCmd != nil && *jsonnetCmd != "" {
|
|
return runJsonnetCommand(i)
|
|
}
|
|
return runInternalJsonnet(i)
|
|
}
|
|
|
|
func compareSingleGolden(path string, result jsonnetResult) []error {
|
|
if result.outputMulti != nil {
|
|
return []error{fmt.Errorf("outputMulti is populated in a single-file test for %v", path)}
|
|
}
|
|
golden, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return []error{fmt.Errorf("reading file %s: %v", path, err)}
|
|
}
|
|
if diff, hasDiff := testutils.CompareWithGolden(result.output, golden); hasDiff {
|
|
return []error{fmt.Errorf("golden file %v has diff:\n%v", path, diff)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func updateSingleGolden(path string, result jsonnetResult) (updated []string, err error) {
|
|
if result.outputMulti != nil {
|
|
return nil, fmt.Errorf("outputMulti is populated in a single-file test for %v", path)
|
|
}
|
|
changed, err := testutils.UpdateGoldenFile(path, []byte(result.output), 0666)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("updating golden file %v: %v", path, err)
|
|
}
|
|
if changed {
|
|
return []string{path}, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func compareMultifileGolden(path string, result jsonnetResult) []error {
|
|
expectFiles, err := os.ReadDir(path)
|
|
if err != nil {
|
|
return []error{fmt.Errorf("reading golden dir %v: %v", path, err)}
|
|
}
|
|
goldenContent := map[string][]byte{}
|
|
var errs []error
|
|
for _, f := range expectFiles {
|
|
golden, err := os.ReadFile(filepath.Join(path, f.Name()))
|
|
if err != nil {
|
|
return []error{fmt.Errorf("reading file %s: %v", f.Name(), err)}
|
|
}
|
|
if _, ok := result.outputMulti[f.Name()]; !ok {
|
|
errs = append(errs, fmt.Errorf("jsonnet did not output expected file %v", f.Name()))
|
|
continue
|
|
}
|
|
goldenContent[f.Name()] = golden
|
|
}
|
|
for fn, content := range result.outputMulti {
|
|
if _, ok := goldenContent[fn]; !ok {
|
|
errs = append(errs, fmt.Errorf("jsonnet outputted file %v which does not exist in goldens", fn))
|
|
continue
|
|
}
|
|
if diff, hasDiff := testutils.CompareWithGolden(content, goldenContent[fn]); hasDiff {
|
|
errs = append(errs, fmt.Errorf("golden file %v has diff:\n%v", fn, diff))
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
func updateMultifileGolden(path string, result jsonnetResult) ([]string, error) {
|
|
expectFiles, err := os.ReadDir(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading golden directory %v: %v", path, err)
|
|
}
|
|
var updatedFiles []string
|
|
for fn, content := range result.outputMulti {
|
|
updated, err := testutils.UpdateGoldenFile(filepath.Join(path, fn), []byte(content), 0666)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("updating golden file %v: %v", fn, err)
|
|
}
|
|
if updated {
|
|
updatedFiles = append(updatedFiles, filepath.Join(path, fn))
|
|
}
|
|
}
|
|
// Delete excess files
|
|
for _, f := range expectFiles {
|
|
if _, ok := result.outputMulti[f.Name()]; ok {
|
|
continue
|
|
}
|
|
if err := os.Remove(filepath.Join(path, f.Name())); err != nil {
|
|
return nil, fmt.Errorf("removing golden file %v: %v", f.Name(), err)
|
|
}
|
|
}
|
|
return updatedFiles, nil
|
|
}
|
|
|
|
func runTest(t *testing.T, test *mainTest) {
|
|
read := func(file string) []byte {
|
|
bytz, err := os.ReadFile(file)
|
|
if err != nil {
|
|
t.Fatalf("reading file: %s: %v", file, err)
|
|
}
|
|
return bytz
|
|
}
|
|
|
|
input := read(test.input)
|
|
|
|
eKind := evalKindRegular
|
|
compareFunc := compareSingleGolden
|
|
updateFunc := updateSingleGolden
|
|
|
|
// If the golden path is a directory, this is a multi-test.
|
|
if info, err := os.Stat(test.golden); err == nil && info.IsDir() {
|
|
eKind = evalKindMulti
|
|
compareFunc = compareMultifileGolden
|
|
updateFunc = updateMultifileGolden
|
|
}
|
|
|
|
result := runJsonnet(jsonnetInput{
|
|
name: test.name,
|
|
input: input,
|
|
eKind: eKind,
|
|
stringOutputMode: strings.HasSuffix(test.golden, "_string_output.golden"),
|
|
extVars: test.meta.extVars,
|
|
extCode: test.meta.extCode,
|
|
})
|
|
|
|
if eKind == evalKindMulti && result.isError {
|
|
// If it's an error, then result.output is populated instead.
|
|
// Since we use the golden file being a directory to determine if we
|
|
// should run in multi-file mode, we put the output into an "error" file instead.
|
|
result.outputMulti = map[string]string{"error": result.output}
|
|
result.output = ""
|
|
}
|
|
|
|
if *update {
|
|
updated, err := updateFunc(test.golden, result)
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
for _, updatedFile := range updated {
|
|
fmt.Fprintf(os.Stderr, "updated golden %v\n", updatedFile)
|
|
}
|
|
return
|
|
}
|
|
for _, err := range compareFunc(test.golden, result) {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
|
|
func TestEval(t *testing.T) {
|
|
files, err := filepath.Glob("testdata/*.jsonnet")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tests := make([]mainTest, 0, len(files))
|
|
for _, input := range files {
|
|
name := strings.TrimSuffix(input, ".jsonnet")
|
|
var meta testMetadata
|
|
if val, exists := metadataForTests[name]; exists {
|
|
meta = val
|
|
}
|
|
tests = append(tests, mainTest{name: name, input: input, golden: name + ".golden", meta: &meta})
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
runTest(t, &test)
|
|
})
|
|
}
|
|
}
|
|
|
|
func withinWorkingDirectory(t *testing.T, dir string) func() {
|
|
t.Helper()
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return func() {
|
|
err := os.Chdir(cwd)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEvalUnusualFilenames(t *testing.T) {
|
|
// Escaped filenames exist because their unescaped forms are invalid on Windows. We have no
|
|
// choice but to skip these in testing.
|
|
if runtime.GOOS == "windows" {
|
|
return
|
|
}
|
|
|
|
// Are we running within "bazel test"?
|
|
dir := os.Getenv("TEST_TMPDIR")
|
|
if len(dir) == 0 {
|
|
var err error
|
|
if dir, err = os.MkdirTemp("", "jsonnet"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
err := os.RemoveAll(dir)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
copySmallFile := func(t *testing.T, dst, src string) {
|
|
b, err := os.ReadFile(src)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(dst, b, 0444); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// These files are imported by files below, but we don't need to exercise their round-trip
|
|
// behavior here, as they're covered by TestEval above.
|
|
for _, f := range []string{"true"} {
|
|
for _, ext := range []string{".jsonnet"} {
|
|
name := f + ext
|
|
copySmallFile(t, filepath.Join(dir, name), filepath.Join("testdata", name))
|
|
}
|
|
}
|
|
|
|
// Temporarily switch into our scratch directory.
|
|
defer withinWorkingDirectory(t, dir)()
|
|
|
|
emptyMetadata := &testMetadata{}
|
|
for _, f := range []struct {
|
|
name string
|
|
content []byte
|
|
golden []byte
|
|
}{
|
|
{
|
|
`"`,
|
|
[]byte(`// This file is there only for its filename: to test escaping in imports
|
|
{}
|
|
`),
|
|
[]byte(`{ }
|
|
`),
|
|
},
|
|
{
|
|
`'`,
|
|
[]byte(`// This file is there only for its filename: to test escaping in imports
|
|
{}
|
|
`),
|
|
[]byte(`{ }
|
|
`),
|
|
},
|
|
{
|
|
"import_various_literals_escaped",
|
|
[]byte(`[
|
|
import "\u0074rue.jsonnet",
|
|
import '\u0074rue.jsonnet',
|
|
importstr @""".jsonnet",
|
|
importstr @'''.jsonnet',
|
|
]
|
|
`),
|
|
[]byte(`[
|
|
true,
|
|
true,
|
|
"// This file is there only for its filename: to test escaping in imports\n{}\n",
|
|
"// This file is there only for its filename: to test escaping in imports\n{}\n"
|
|
]
|
|
`),
|
|
},
|
|
{
|
|
"importstr_various_literals_escaped",
|
|
[]byte(`[
|
|
importstr "\u0074rue.jsonnet",
|
|
importstr '\u0074rue.jsonnet',
|
|
importstr @""".jsonnet",
|
|
importstr @'''.jsonnet',
|
|
]
|
|
`),
|
|
[]byte(`[
|
|
"true\n",
|
|
"true\n",
|
|
"// This file is there only for its filename: to test escaping in imports\n{}\n",
|
|
"// This file is there only for its filename: to test escaping in imports\n{}\n"
|
|
]
|
|
`),
|
|
},
|
|
} {
|
|
if err := os.WriteFile(f.name+".jsonnet", f.content, 0444); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(f.name+".golden", f.golden, 0444); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Run(f.name, func(t *testing.T) {
|
|
runTest(t, &mainTest{
|
|
name: f.name,
|
|
input: f.name + ".jsonnet",
|
|
golden: f.name + ".golden",
|
|
meta: emptyMetadata,
|
|
})
|
|
})
|
|
}
|
|
}
|