github.com/data-DOG/godog@v0.7.9/builder.go (about)

     1  // +build !go1.10
     2  
     3  package godog
     4  
     5  import (
     6  	"bytes"
     7  	"fmt"
     8  	"go/build"
     9  	"go/parser"
    10  	"go/token"
    11  	"io/ioutil"
    12  	"os"
    13  	"os/exec"
    14  	"path"
    15  	"path/filepath"
    16  	"strings"
    17  	"text/template"
    18  	"time"
    19  	"unicode"
    20  )
    21  
    22  var tooldir = findToolDir()
    23  var compiler = filepath.Join(tooldir, "compile")
    24  var linker = filepath.Join(tooldir, "link")
    25  var gopaths = filepath.SplitList(build.Default.GOPATH)
    26  var goarch = build.Default.GOARCH
    27  var goos = build.Default.GOOS
    28  
    29  var godogImportPath = "github.com/DATA-DOG/godog"
    30  var runnerTemplate = template.Must(template.New("testmain").Parse(`package main
    31  
    32  import (
    33  	"github.com/DATA-DOG/godog"
    34  	{{if .Contexts}}_test "{{.ImportPath}}"{{end}}
    35  	"os"
    36  )
    37  
    38  func main() {
    39  	status := godog.Run("{{ .Name }}", func (suite *godog.Suite) {
    40  		os.Setenv("GODOG_TESTED_PACKAGE", "{{.ImportPath}}")
    41  		{{range .Contexts}}
    42  			_test.{{ . }}(suite)
    43  		{{end}}
    44  	})
    45  	os.Exit(status)
    46  }`))
    47  
    48  // Build creates a test package like go test command at given target path.
    49  // If there are no go files in tested directory, then
    50  // it simply builds a godog executable to scan features.
    51  //
    52  // If there are go test files, it first builds a test
    53  // package with standard go test command.
    54  //
    55  // Finally it generates godog suite executable which
    56  // registers exported godog contexts from the test files
    57  // of tested package.
    58  //
    59  // Returns the path to generated executable
    60  func Build(bin string) error {
    61  	abs, err := filepath.Abs(".")
    62  	if err != nil {
    63  		return err
    64  	}
    65  
    66  	// we allow package to be nil, if godog is run only when
    67  	// there is a feature file in empty directory
    68  	pkg := importPackage(abs)
    69  	src, anyContexts, err := buildTestMain(pkg)
    70  	if err != nil {
    71  		return err
    72  	}
    73  
    74  	workdir := fmt.Sprintf(filepath.Join("%s", "godog-%d"), os.TempDir(), time.Now().UnixNano())
    75  	testdir := workdir
    76  
    77  	// if none of test files exist, or there are no contexts found
    78  	// we will skip test package compilation, since it is useless
    79  	if anyContexts {
    80  		// first of all compile test package dependencies
    81  		// that will save was many compilations for dependencies
    82  		// go does it better
    83  		out, err := exec.Command("go", "test", "-i").CombinedOutput()
    84  		if err != nil {
    85  			return fmt.Errorf("failed to compile package: %s, reason: %v, output: %s", pkg.Name, err, string(out))
    86  		}
    87  
    88  		// build and compile the tested package.
    89  		// generated test executable will be removed
    90  		// since we do not need it for godog suite.
    91  		// we also print back the temp WORK directory
    92  		// go has built. We will reuse it for our suite workdir.
    93  		// go1.5 does not support os.DevNull as void output
    94  		temp := fmt.Sprintf(filepath.Join("%s", "temp-%d.test"), os.TempDir(), time.Now().UnixNano())
    95  		out, err = exec.Command("go", "test", "-c", "-work", "-o", temp).CombinedOutput()
    96  		if err != nil {
    97  			return fmt.Errorf("failed to compile tested package: %s, reason: %v, output: %s", pkg.Name, err, string(out))
    98  		}
    99  		defer os.Remove(temp)
   100  
   101  		// extract go-build temporary directory as our workdir
   102  		lines := strings.Split(strings.TrimSpace(string(out)), "\n")
   103  		// it may have some compilation warnings, in the output, but these are not
   104  		// considered to be errors, since command exit status is 0
   105  		for _, ln := range lines {
   106  			if !strings.HasPrefix(ln, "WORK=") {
   107  				continue
   108  			}
   109  			workdir = strings.Replace(ln, "WORK=", "", 1)
   110  			break
   111  		}
   112  
   113  		// may not locate it in output
   114  		if workdir == testdir {
   115  			return fmt.Errorf("expected WORK dir path to be present in output: %s", string(out))
   116  		}
   117  
   118  		// check whether workdir exists
   119  		stats, err := os.Stat(workdir)
   120  		if os.IsNotExist(err) {
   121  			return fmt.Errorf("expected WORK dir: %s to be available", workdir)
   122  		}
   123  
   124  		if !stats.IsDir() {
   125  			return fmt.Errorf("expected WORK dir: %s to be directory", workdir)
   126  		}
   127  		testdir = filepath.Join(workdir, pkg.ImportPath, "_test")
   128  	} else {
   129  		// still need to create temporary workdir
   130  		if err = os.MkdirAll(testdir, 0755); err != nil {
   131  			return err
   132  		}
   133  	}
   134  	defer os.RemoveAll(workdir)
   135  
   136  	// replace _testmain.go file with our own
   137  	testmain := filepath.Join(testdir, "_testmain.go")
   138  	err = ioutil.WriteFile(testmain, src, 0644)
   139  	if err != nil {
   140  		return err
   141  	}
   142  
   143  	// godog library may not be imported in tested package
   144  	// but we need it for our testmain package.
   145  	// So we look it up in available source paths
   146  	// including vendor directory, supported since 1.5.
   147  	try := maybeVendorPaths(abs)
   148  	for _, d := range build.Default.SrcDirs() {
   149  		try = append(try, filepath.Join(d, godogImportPath))
   150  	}
   151  	godogPkg, err := locatePackage(try)
   152  	if err != nil {
   153  		return err
   154  	}
   155  
   156  	// make sure godog package archive is installed, gherkin
   157  	// will be installed as dependency of godog
   158  	cmd := exec.Command("go", "install", godogPkg.ImportPath)
   159  	cmd.Env = os.Environ()
   160  	out, err := cmd.CombinedOutput()
   161  	if err != nil {
   162  		return fmt.Errorf("failed to install godog package: %s, reason: %v", string(out), err)
   163  	}
   164  
   165  	// collect all possible package dirs, will be
   166  	// used for includes and linker
   167  	pkgDirs := []string{workdir, testdir}
   168  	for _, gopath := range gopaths {
   169  		pkgDirs = append(pkgDirs, filepath.Join(gopath, "pkg", goos+"_"+goarch))
   170  	}
   171  	pkgDirs = uniqStringList(pkgDirs)
   172  
   173  	// compile godog testmain package archive
   174  	// we do not depend on CGO so a lot of checks are not necessary
   175  	testMainPkgOut := filepath.Join(testdir, "main.a")
   176  	args := []string{
   177  		"-o", testMainPkgOut,
   178  		// "-trimpath", workdir,
   179  		"-p", "main",
   180  		"-complete",
   181  	}
   182  	// if godog library is in vendor directory
   183  	// link it with import map
   184  	if i := strings.LastIndex(godogPkg.ImportPath, "vendor/"); i != -1 {
   185  		args = append(args, "-importmap", godogImportPath+"="+godogPkg.ImportPath)
   186  	}
   187  	for _, inc := range pkgDirs {
   188  		args = append(args, "-I", inc)
   189  	}
   190  	args = append(args, "-pack", testmain)
   191  	cmd = exec.Command(compiler, args...)
   192  	cmd.Env = os.Environ()
   193  	out, err = cmd.CombinedOutput()
   194  	if err != nil {
   195  		return fmt.Errorf("failed to compile testmain package: %v - output: %s", err, string(out))
   196  	}
   197  
   198  	// link test suite executable
   199  	args = []string{
   200  		"-o", bin,
   201  		"-buildmode=exe",
   202  	}
   203  	for _, link := range pkgDirs {
   204  		args = append(args, "-L", link)
   205  	}
   206  	args = append(args, testMainPkgOut)
   207  	cmd = exec.Command(linker, args...)
   208  	cmd.Env = os.Environ()
   209  
   210  	out, err = cmd.CombinedOutput()
   211  	if err != nil {
   212  		msg := `failed to link test executable:
   213  	reason: %s
   214  	command: %s`
   215  		return fmt.Errorf(msg, string(out), linker+" '"+strings.Join(args, "' '")+"'")
   216  	}
   217  
   218  	return nil
   219  }
   220  
   221  func locatePackage(try []string) (*build.Package, error) {
   222  	for _, p := range try {
   223  		abs, err := filepath.Abs(p)
   224  		if err != nil {
   225  			continue
   226  		}
   227  		pkg, err := build.ImportDir(abs, 0)
   228  		if err != nil {
   229  			continue
   230  		}
   231  		return pkg, nil
   232  	}
   233  	return nil, fmt.Errorf("failed to find godog package in any of:\n%s", strings.Join(try, "\n"))
   234  }
   235  
   236  func importPackage(dir string) *build.Package {
   237  	pkg, _ := build.ImportDir(dir, 0)
   238  
   239  	// normalize import path for local import packages
   240  	// taken from go source code
   241  	// see: https://github.com/golang/go/blob/go1.7rc5/src/cmd/go/pkg.go#L279
   242  	if pkg != nil && pkg.ImportPath == "." {
   243  		pkg.ImportPath = path.Join("_", strings.Map(makeImportValid, filepath.ToSlash(dir)))
   244  	}
   245  
   246  	return pkg
   247  }
   248  
   249  // from go src
   250  func makeImportValid(r rune) rune {
   251  	// Should match Go spec, compilers, and ../../go/parser/parser.go:/isValidImport.
   252  	const illegalChars = `!"#$%&'()*,:;<=>?[\]^{|}` + "`\uFFFD"
   253  	if !unicode.IsGraphic(r) || unicode.IsSpace(r) || strings.ContainsRune(illegalChars, r) {
   254  		return '_'
   255  	}
   256  	return r
   257  }
   258  
   259  type void struct{}
   260  
   261  func uniqStringList(strs []string) (unique []string) {
   262  	uniq := make(map[string]void, len(strs))
   263  	for _, s := range strs {
   264  		if _, ok := uniq[s]; !ok {
   265  			uniq[s] = void{}
   266  			unique = append(unique, s)
   267  		}
   268  	}
   269  	return
   270  }
   271  
   272  // buildTestMain if given package is valid
   273  // it scans test files for contexts
   274  // and produces a testmain source code.
   275  func buildTestMain(pkg *build.Package) ([]byte, bool, error) {
   276  	var contexts []string
   277  	var importPath string
   278  	name := "main"
   279  	if nil != pkg {
   280  		ctxs, err := processPackageTestFiles(
   281  			pkg.TestGoFiles,
   282  			pkg.XTestGoFiles,
   283  		)
   284  		if err != nil {
   285  			return nil, false, err
   286  		}
   287  		contexts = ctxs
   288  		importPath = pkg.ImportPath
   289  		name = pkg.Name
   290  	}
   291  
   292  	data := struct {
   293  		Name       string
   294  		Contexts   []string
   295  		ImportPath string
   296  	}{name, contexts, importPath}
   297  
   298  	var buf bytes.Buffer
   299  	if err := runnerTemplate.Execute(&buf, data); err != nil {
   300  		return nil, len(contexts) > 0, err
   301  	}
   302  	return buf.Bytes(), len(contexts) > 0, nil
   303  }
   304  
   305  // maybeVendorPaths determines possible vendor paths
   306  // which goes levels down from given directory
   307  // until it reaches GOPATH source dir
   308  func maybeVendorPaths(dir string) (paths []string) {
   309  	for _, gopath := range gopaths {
   310  		gopath = filepath.Join(gopath, "src")
   311  		for strings.HasPrefix(dir, gopath) && dir != gopath {
   312  			paths = append(paths, filepath.Join(dir, "vendor", godogImportPath))
   313  			dir = filepath.Dir(dir)
   314  		}
   315  	}
   316  	return
   317  }
   318  
   319  // processPackageTestFiles runs through ast of each test
   320  // file pack and looks for godog suite contexts to register
   321  // on run
   322  func processPackageTestFiles(packs ...[]string) ([]string, error) {
   323  	var ctxs []string
   324  	fset := token.NewFileSet()
   325  	for _, pack := range packs {
   326  		for _, testFile := range pack {
   327  			node, err := parser.ParseFile(fset, testFile, nil, 0)
   328  			if err != nil {
   329  				return ctxs, err
   330  			}
   331  
   332  			ctxs = append(ctxs, astContexts(node)...)
   333  		}
   334  	}
   335  	var failed []string
   336  	for _, ctx := range ctxs {
   337  		runes := []rune(ctx)
   338  		if unicode.IsLower(runes[0]) {
   339  			expected := append([]rune{unicode.ToUpper(runes[0])}, runes[1:]...)
   340  			failed = append(failed, fmt.Sprintf("%s - should be: %s", ctx, string(expected)))
   341  		}
   342  	}
   343  	if len(failed) > 0 {
   344  		return ctxs, fmt.Errorf("godog contexts must be exported:\n\t%s", strings.Join(failed, "\n\t"))
   345  	}
   346  	return ctxs, nil
   347  }
   348  
   349  func findToolDir() string {
   350  	if out, err := exec.Command("go", "env", "GOTOOLDIR").Output(); err != nil {
   351  		return filepath.Clean(strings.TrimSpace(string(out)))
   352  	}
   353  	return filepath.Clean(build.ToolDir)
   354  }