Skip to content

Commit

Permalink
Bootstrap txtar-based jennies tests
Browse files Browse the repository at this point in the history
  • Loading branch information
K-Phoen committed Sep 11, 2023
1 parent db5df15 commit 9735375
Show file tree
Hide file tree
Showing 4 changed files with 434 additions and 0 deletions.
11 changes: 11 additions & 0 deletions internal/envvars/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package envvars

import "os"

// VarUpdateGolden is the name of the env var to trigger updating golden test files.
const VarUpdateGolden = "COG_UPDATE_GOLDEN"

// UpdateGoldenFiles determines whether tests should update txtar
// archives on failures.
// It is controlled by setting COG_UPDATE_GOLDEN to a non-empty string like "true".
var UpdateGoldenFiles = os.Getenv(VarUpdateGolden) != ""

Check failure on line 11 in internal/envvars/env.go

View workflow job for this annotation

GitHub Actions / Linters

UpdateGoldenFiles is a global variable (gochecknoglobals)
26 changes: 26 additions & 0 deletions internal/jennies/typescript/rawtypes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package typescript

import (
"testing"

"github.com/grafana/cog/internal/txtartest"
"github.com/stretchr/testify/require"
)

func TestRawTypes_Generate(t *testing.T) {
test := txtartest.TxTarTest{
Root: "../../../testdata/jennies/rawtypes",
Name: "jennies/TypescriptRawTypes",
}

jenny := RawTypes{}

test.Run(t, func(tc *txtartest.Test) {
req := require.New(tc)

files, err := jenny.Generate(tc.TypesIR())
req.NoError(err)

tc.WriteFiles(files)
})
}
359 changes: 359 additions & 0 deletions internal/txtartest/txtar.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,359 @@
package txtartest

import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"testing"

"github.com/grafana/codejen"
"github.com/grafana/cog/internal/ast"
"github.com/grafana/cog/internal/envvars"
"github.com/stretchr/testify/assert"
"golang.org/x/tools/txtar"
)

// A TxTarTest represents a test run that processes all txtar-formatted files
// from a given directory. See the [Test] documentation for
// more details.
type TxTarTest struct {
// Run TxTarTest on this directory.
Root string

// Name is a unique name for this test. The golden file for this test is
// derived from the out/<name> file in the .txtar file.
Name string

// Skip is a map of tests to skip; the key is the test name; the value is the
// skip message.
Skip map[string]string

// ToDo is a map of tests that should be skipped now, but should be fixed.
ToDo map[string]string
}

// A Test represents a single test based on a .txtar file.
//
// A Test embeds *[testing.T] and should be used to report errors.
//
// Entries within the txtar file define a JSON-marshaled intermediate representation
// under the `ir.json` file. It can be accessed in tests with [Test.TypesIR].
//
// The rest of the .txtar file contains the results of running code generation jennies
// on that intermediate representation.
//
// These test cases (or "golden") files have names starting with "out/\(testname)". The "main" golden
// file is "out/\(testname)" itself, used when [Test] is used directly as an [io.Writer]
// and with [Test.WriteFile].
//
// When the test function has returned, output written with [Test.Write], [Test.Writer],
// [Test.WriteFile] and friends is checked against the expected output files.
//
// A txtar file can define test-specific tags and values in the comment section.
// These are available via the [Test.HasTag] and [Test.Value] methods.
// The #skip tag causes a [Test] to be skipped.
//
// If the output differs and $COG_UPDATE_GOLDEN is non-empty, the txtar file will be
// updated and written to disk with the actual output data replacing the
// out files.
type Test struct {
// Allow Test to be used as a T.
*testing.T

prefix string
buf *bytes.Buffer // the default buffer
outFiles []file

Archive *txtar.Archive

// The absolute path of the current test directory.
Dir string

hasGold bool
}

// WriteFile writes a [codejen.File] to the main output,
// prefixed by a line of the form:
//
// == name
//
// where name is the base name of f.RelativePath.
func (t *Test) WriteFile(f *codejen.File) {
// TODO: use FileWriter instead in separate CL.
fmt.Fprintln(t, "==", f.RelativePath)
_, _ = t.Write(f.Data)
}

// WriteFiles writes a list of [codejen.File] to the main output.
func (t *Test) WriteFiles(files codejen.Files) {
for i := range files {
t.WriteFile(&files[i])
}
}

// Write implements [io.Writer] by writing to the output for the test,
// which will be tested against the main golden file.
func (t *Test) Write(b []byte) (n int, err error) {

Check failure on line 103 in internal/txtartest/txtar.go

View workflow job for this annotation

GitHub Actions / Linters

named return "n" with type "int" found (nonamedreturns)
if t.buf == nil {
t.buf = &bytes.Buffer{}
t.outFiles = append(t.outFiles, file{t.prefix, t.buf})
}
return t.buf.Write(b)

Check failure on line 108 in internal/txtartest/txtar.go

View workflow job for this annotation

GitHub Actions / Linters

return with no blank line before (nlreturn)
}

// HasTag reports whether the tag with the given key is defined
// for the current test. A tag x is defined by a line in the comment
// section of the txtar file like:
//
// #x
func (t *Test) HasTag(key string) bool {
prefix := []byte("#" + key)
s := bufio.NewScanner(bytes.NewReader(t.Archive.Comment))
for s.Scan() {
b := s.Bytes()
if bytes.Equal(bytes.TrimSpace(b), prefix) {
return true
}
}
return false

Check failure on line 125 in internal/txtartest/txtar.go

View workflow job for this annotation

GitHub Actions / Linters

return with no blank line before (nlreturn)
}

// Value returns the value for the given key for this test and
// reports whether it was defined.
//
// A value is defined by a line in the comment section of the txtar
// file like:
//
// #key: value
//
// White space is trimmed from the value before returning.
func (t *Test) Value(key string) (value string, ok bool) {
prefix := []byte("#" + key + ":")
s := bufio.NewScanner(bytes.NewReader(t.Archive.Comment))
for s.Scan() {
b := s.Bytes()
if bytes.HasPrefix(b, prefix) {
return string(bytes.TrimSpace(b[len(prefix):])), true
}
}
return "", false

Check failure on line 146 in internal/txtartest/txtar.go

View workflow job for this annotation

GitHub Actions / Linters

return with no blank line before (nlreturn)
}

// Bool searches for a line starting with #key: value in the comment and
// reports whether the key exists and its value is true.
func (t *Test) Bool(key string) bool {
s, ok := t.Value(key)
return ok && s == "true"
}

// TypesIR locates and returns the raw types intermediate representation described
// within the txtar archive.
func (t *Test) TypesIR() *ast.File {
for _, f := range t.Archive.Files {
if f.Name != "ir.json" {
continue
}

parsedIR := &ast.File{}
if err := json.Unmarshal(f.Data, parsedIR); err != nil {
t.Fatalf("could not load types IR: %s", err)
}

return parsedIR
}

// the ir.json file wasn't found, let's fail hard.
t.Fatal("could not load types IR: file 'ir.json' not found in test archive")

return nil
}

// Writer returns a Writer with the given name. Data written will
// be checked against the file with name "out/\(testName)/\(name)"
// in the txtar file. If name is empty, data will be written to the test
// output and checked against "out/\(testName)".
func (t *Test) Writer(name string) io.Writer {
switch name {
case "":
name = t.prefix
default:
name = path.Join(t.prefix, name)
}

for _, f := range t.outFiles {
if f.name == name {
return f.buf
}
}

w := &bytes.Buffer{}
t.outFiles = append(t.outFiles, file{name, w})

if name == t.prefix {
t.buf = w
}

return w
}

// Run runs tests defined in txtar files in x.Root or its subdirectories.
//
// The function f is called for each such txtar file. See the [Test] documentation
// for more details.
func (x *TxTarTest) Run(t *testing.T, f func(tc *Test)) {

Check failure on line 210 in internal/txtartest/txtar.go

View workflow job for this annotation

GitHub Actions / Linters

cognitive complexity 73 of func `(*TxTarTest).Run` is high (> 30) (gocognit)
t.Helper()

dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}

err = filepath.WalkDir(x.Root, func(fullpath string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() || filepath.Ext(fullpath) != ".txtar" {
return nil
}

str := filepath.ToSlash(fullpath)
p := strings.Index(str, "/testdata/")
testName := str[p+len("/testdata/") : len(str)-len(".txtar")]

t.Run(testName, func(t *testing.T) {
a, err := txtar.ParseFile(fullpath)
if err != nil {
t.Fatalf("error parsing txtar file: %v", err)
}

tc := &Test{
T: t,
Archive: a,
Dir: filepath.Dir(filepath.Join(dir, fullpath)),
prefix: path.Join("out", x.Name),
}

if tc.HasTag("skip") {
t.Skip()
}
if testing.Short() && tc.HasTag("slow") {
tc.Skip("case is tagged #slow, skipping for -short")
}

if msg, ok := x.Skip[testName]; ok {
t.Skip(msg)
}
if msg, ok := x.ToDo[testName]; ok {
t.Skip(msg)
}

update := false
for _, f := range a.Files {
if strings.HasPrefix(f.Name, tc.prefix) && (f.Name == tc.prefix || f.Name[len(tc.prefix)] == '/') {
// It's either "\(tc.prefix)" or "\(tc.prefix)/..." but not some other name
// that happens to start with tc.prefix.
tc.hasGold = true
}
}

f(tc)

// TODO we MAY need the below if trying to enable parallel tests
//
// Lock and re-parse the txtar file now that test execution is done. This does
// make for some weird edge cases, but as long as underlying fs supports file
// locking (windows? :scream:) it should make it safe to run multiple tests on same
// txtar archive in parallel.
// lock := flock.New(fullpath)
// defer lock.Unlock()
// a, err = txtar.ParseFile(fullpath)
// if err != nil {
// t.Fatalf("error parsing txtar file: %v", err)
// }

index := make(map[string]int, len(a.Files))
for i, f := range a.Files {
index[f.Name] = i
}

// Insert results of this test at first location of any existing
// test or at end of list otherwise.
k := len(a.Files)
for _, sub := range tc.outFiles {
if i, ok := index[sub.name]; ok {
k = i
break

Check failure on line 292 in internal/txtartest/txtar.go

View workflow job for this annotation

GitHub Actions / Linters

break with no blank line before (nlreturn)
}
}

files := a.Files[:k:k]

for _, sub := range tc.outFiles {
result := sub.buf.Bytes()

files = append(files, txtar.File{Name: sub.name})
gold := &files[len(files)-1]

if i, ok := index[sub.name]; ok {
gold.Data = a.Files[i].Data
delete(index, sub.name)

if bytes.Equal(gold.Data, result) || bytes.Equal(bytes.TrimRight(gold.Data, "\n"), result) {
continue
}
}

if envvars.UpdateGoldenFiles {
update = true
gold.Data = result
continue

Check failure on line 316 in internal/txtartest/txtar.go

View workflow job for this annotation

GitHub Actions / Linters

continue with no blank line before (nlreturn)
}

assert.Equal(t, string(gold.Data), string(result), "result for %s differs", sub.name)
}

// Add remaining unrelated files, ignoring files that were already
// added.
for _, f := range a.Files[k:] {
if _, ok := index[f.Name]; ok {
files = append(files, f)
}
}
a.Files = files

if update {
err = os.WriteFile(fullpath, txtar.Format(a), 0644)

Check failure on line 332 in internal/txtartest/txtar.go

View workflow job for this annotation

GitHub Actions / Linters

G306: Expect WriteFile permissions to be 0600 or less (gosec)
if err != nil {
t.Fatal(err)
}
}
})

return nil
})

if err != nil {
t.Fatal(err)
}
}

func DumpTestInfo(tc *Test) {
fmt.Println("=== TEST:", tc.Dir, tc.prefix)
fmt.Println("=== Files")
for _, f := range tc.Archive.Files {
fmt.Printf("=== %s\n", f.Name)
}
fmt.Println("=== END TEST")
}

type file struct {
name string
buf *bytes.Buffer
}
Loading

0 comments on commit 9735375

Please sign in to comment.