github.com/devcamcar/cli@v0.0.0-20181107134215-706a05759d18/testharness/harness.go (about)

     1  package testharness
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io/ioutil"
     8  	"log"
     9  	"math/rand"
    10  	"os"
    11  	"os/exec"
    12  	"path"
    13  	"path/filepath"
    14  	"regexp"
    15  	"strings"
    16  	"syscall"
    17  	"testing"
    18  	"time"
    19  
    20  	"github.com/fnproject/cli/common"
    21  	"github.com/ghodss/yaml"
    22  	"github.com/jmoiron/jsonq"
    23  )
    24  
    25  // Max duration a command can run for before being killed
    26  var commandTimeout = 5 * time.Minute
    27  
    28  type funcRef struct {
    29  	appName, funcName string
    30  }
    31  
    32  type triggerRef struct {
    33  	appName, funcName, triggerName string
    34  }
    35  
    36  //CLIHarness encapsulates a single CLI session
    37  type CLIHarness struct {
    38  	t           *testing.T
    39  	cliPath     string
    40  	appNames    []string
    41  	funcRefs    []funcRef
    42  	triggerRefs []triggerRef
    43  	testDir     string
    44  	homeDir     string
    45  	cwd         string
    46  
    47  	env     map[string]string
    48  	history []string
    49  }
    50  
    51  //CmdResult wraps the result of a single command - this includes the diagnostic history of commands that led to this command in a given context for easier test diagnosis
    52  type CmdResult struct {
    53  	t               *testing.T
    54  	OriginalCommand string
    55  	Cwd             string
    56  	ExtraEnv        []string
    57  	Stdout          string
    58  	Stderr          string
    59  	Input           string
    60  	ExitState       *os.ProcessState
    61  	Success         bool
    62  	History         []string
    63  }
    64  
    65  func (cr *CmdResult) String() string {
    66  	return fmt.Sprintf(`COMMAND: %s
    67  INPUT:%s
    68  RESULT: %t
    69  STDERR:
    70  %s
    71  STDOUT:%s
    72  HISTORY:
    73  %s
    74  EXTRAENV:
    75  %s
    76  EXITSTATE: %s
    77  `, cr.OriginalCommand,
    78  		cr.Input,
    79  		cr.Success,
    80  		cr.Stderr,
    81  		cr.Stdout,
    82  		strings.Join(cr.History, "\n"),
    83  		strings.Join(cr.ExtraEnv, "\n"),
    84  		cr.ExitState)
    85  }
    86  
    87  //AssertSuccess checks the command was success
    88  func (cr *CmdResult) AssertSuccess() *CmdResult {
    89  	if !cr.Success {
    90  		cr.t.Fatalf("Command failed but should have succeeded: \n%s", cr.String())
    91  	}
    92  	return cr
    93  }
    94  
    95  // AssertStdoutContains asserts that the string appears somewhere in the stdout
    96  func (cr *CmdResult) AssertStdoutContains(match string) *CmdResult {
    97  	if !strings.Contains(cr.Stdout, match) {
    98  		log.Fatalf("Expected stdout  message (%s) not found in result: %v", match, cr)
    99  	}
   100  	return cr
   101  }
   102  
   103  // AssertStdoutContains asserts that the string appears somewhere in the stderr
   104  func (cr *CmdResult) AssertStderrContains(match string) *CmdResult {
   105  	if !strings.Contains(cr.Stderr, match) {
   106  		log.Fatalf("Expected sdterr message (%s) not found in result: %v", match, cr)
   107  	}
   108  	return cr
   109  }
   110  
   111  // AssertFailed asserts that the command did not succeed
   112  func (cr *CmdResult) AssertFailed() *CmdResult {
   113  	if cr.Success {
   114  		cr.t.Fatalf("Command succeeded but should have failed: \n%s", cr.String())
   115  	}
   116  	return cr
   117  }
   118  
   119  // AssertStdoutEmpty fails if there was output to stdout
   120  func (cr *CmdResult) AssertStdoutEmpty() {
   121  	if cr.Stdout != "" {
   122  		cr.t.Fatalf("Expecting empty stdout, got %v", cr)
   123  	}
   124  }
   125  
   126  func randString(n int) string {
   127  
   128  	var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz")
   129  	b := make([]rune, n)
   130  	for i := range b {
   131  		b[i] = letterRunes[rand.Intn(len(letterRunes))]
   132  	}
   133  	return string(b)
   134  }
   135  
   136  // Create creates a CLI test harness that that runs CLI operations in a test directory
   137  // test harness operations will propagate most environment variables to tests (with the exception of HOME, which is faked)
   138  func Create(t *testing.T) *CLIHarness {
   139  	testDir, err := ioutil.TempDir("", "")
   140  	if err != nil {
   141  		t.Fatal("Failed to create temp dir")
   142  	}
   143  
   144  	homeDir, err := ioutil.TempDir("", "")
   145  	if err != nil {
   146  		t.Fatal("Failed to create home dir")
   147  	}
   148  
   149  	cliPath := os.Getenv("TEST_CLI_BINARY")
   150  
   151  	if cliPath == "" {
   152  		wd, err := os.Getwd()
   153  		if err != nil {
   154  			t.Fatalf("Failed to get CWD, %v", err)
   155  		}
   156  
   157  		cliPath = path.Join(wd, "../fn")
   158  	}
   159  	ctx := &CLIHarness{
   160  		t:       t,
   161  		cliPath: cliPath,
   162  		testDir: testDir,
   163  		homeDir: homeDir,
   164  		cwd:     testDir,
   165  		env: map[string]string{
   166  			"HOME": homeDir,
   167  		},
   168  	}
   169  	ctx.pushHistoryf("cd %s", ctx.cwd)
   170  	return ctx
   171  }
   172  
   173  // Cleanup removes any temporary files and tries to delete any apps that (may) have been created during a test
   174  func (h *CLIHarness) Cleanup() {
   175  
   176  	h.Cd("")
   177  	for _, trigger := range h.triggerRefs {
   178  		h.Fn("delete", "triggers", trigger.appName, trigger.funcName, trigger.triggerName)
   179  	}
   180  	for _, fn := range h.funcRefs {
   181  		h.Fn("delete", "functions", fn.appName, fn.funcName)
   182  	}
   183  	for _, app := range h.appNames {
   184  		h.Fn("delete", "apps", app)
   185  	}
   186  
   187  	os.RemoveAll(h.testDir)
   188  	os.RemoveAll(h.homeDir)
   189  
   190  }
   191  
   192  //NewAppName creates a new, valid app name and registers it for deletion
   193  func (h *CLIHarness) NewAppName() string {
   194  	appName := randString(8)
   195  	h.appNames = append(h.appNames, appName)
   196  	return appName
   197  }
   198  
   199  //WithEnv sets additional enironment variables in the test , these overlay the ambient environment
   200  func (h *CLIHarness) WithEnv(key string, value string) {
   201  	h.env[key] = value
   202  }
   203  
   204  func copyAll(src, dest string) error {
   205  	srcinfo, err := os.Stat(src)
   206  	if err != nil {
   207  		return err
   208  	}
   209  
   210  	if srcinfo.IsDir() {
   211  
   212  		os.MkdirAll(dest, srcinfo.Mode())
   213  		directory, err := os.Open(src)
   214  		if err != nil {
   215  			return fmt.Errorf("Failed to open directory %s: %v ", src, err)
   216  
   217  		}
   218  
   219  		objects, err := directory.Readdir(-1)
   220  		if err != nil {
   221  			return fmt.Errorf("Failed to read directory %s: %v ", src, err)
   222  		}
   223  
   224  		for _, obj := range objects {
   225  			srcPath := path.Join(src, obj.Name())
   226  			destPath := path.Join(dest, obj.Name())
   227  			err := copyAll(srcPath, destPath)
   228  			if err != nil {
   229  				return err
   230  			}
   231  		}
   232  	} else {
   233  
   234  		dstDir := filepath.Dir(dest)
   235  		srcDir := filepath.Dir(src)
   236  
   237  		srcDirInfo, err := os.Stat(srcDir)
   238  		if err != nil {
   239  			return err
   240  		}
   241  
   242  		os.MkdirAll(dstDir, srcDirInfo.Mode())
   243  
   244  		b, err := ioutil.ReadFile(src)
   245  		if err != nil {
   246  			return fmt.Errorf("Failed to read src file %s: %v ", src, err)
   247  		}
   248  
   249  		err = ioutil.WriteFile(dest, b, srcinfo.Mode())
   250  		if err != nil {
   251  			return fmt.Errorf("Failed to read dst file %s: %v ", dest, err)
   252  		}
   253  	}
   254  	return nil
   255  }
   256  
   257  // CopyFiles copies files and directories from the test source dir into the testing root directory
   258  func (h *CLIHarness) CopyFiles(files map[string]string) {
   259  
   260  	for src, dest := range files {
   261  		h.pushHistoryf("cp -r %s %s", src, dest)
   262  		err := copyAll(src, path.Join(h.cwd, dest))
   263  		if err != nil {
   264  			h.t.Fatalf("Failed to copy %s -> %s : %v", src, dest, err)
   265  		}
   266  	}
   267  
   268  }
   269  
   270  // WithFile creates a file relative to the cwd
   271  func (h *CLIHarness) WithFile(rPath string, content string, perm os.FileMode) {
   272  
   273  	fullPath := h.relativeToCwd(rPath)
   274  
   275  	err := ioutil.WriteFile(fullPath, []byte(content), perm)
   276  	if err != nil {
   277  		fmt.Println("ERR: ", err)
   278  		h.t.Fatalf("Failed to create file %s", fullPath)
   279  	}
   280  	h.pushHistoryf("echo `%s` > %s", content, fullPath)
   281  
   282  }
   283  
   284  // FnWithInput runs the Fn ClI with an input string
   285  // If a command takes more than a certain timeout then this will send a SIGQUIT to the process resulting in a stacktrace on stderr
   286  func (h *CLIHarness) FnWithInput(input string, args ...string) *CmdResult {
   287  
   288  	stdOut := bytes.Buffer{}
   289  	stdErr := bytes.Buffer{}
   290  
   291  	args = append([]string{"--verbose"}, args...)
   292  	cmd := exec.Command(h.cliPath, args...)
   293  	cmd.Stderr = &stdErr
   294  	cmd.Stdout = &stdOut
   295  
   296  	stdIn := bytes.NewBufferString(input)
   297  
   298  	cmd.Dir = h.cwd
   299  	envRegex := regexp.MustCompile("([^=]+)=(.*)")
   300  
   301  	mergedEnv := map[string]string{}
   302  
   303  	for _, e := range os.Environ() {
   304  		m := envRegex.FindStringSubmatch(e)
   305  		if len(m) != 3 {
   306  			panic("Invalid env entry")
   307  		}
   308  		mergedEnv[m[1]] = m[2]
   309  	}
   310  
   311  	extraEnv := make([]string, 0, len(h.env))
   312  
   313  	for k, v := range h.env {
   314  		mergedEnv[k] = v
   315  		extraEnv = append(extraEnv, fmt.Sprintf("%s=%s", k, v))
   316  	}
   317  	env := make([]string, 0, len(mergedEnv))
   318  
   319  	for k, v := range mergedEnv {
   320  		env = append(env, fmt.Sprintf("%s=%s", k, v))
   321  	}
   322  
   323  	cmd.Env = env
   324  	cmd.Stdin = stdIn
   325  	cmdString := h.cliPath + " " + strings.Join(args, " ")
   326  
   327  	if input != "" {
   328  		h.pushHistoryf("echo '%s' | %s", input, cmdString)
   329  	} else {
   330  		h.pushHistoryf("%s", cmdString)
   331  	}
   332  	done := make(chan interface{})
   333  	timer := time.NewTimer(commandTimeout)
   334  
   335  	// If the CLI stalls for more than commandTimeout we send a SIQQUIT which should result in a stack trace in stderr
   336  	go func() {
   337  		select {
   338  		case <-done:
   339  			return
   340  		case <-timer.C:
   341  			h.t.Errorf("Command timed out - killing CLI with SIGQUIT - see STDERR log for stack trace of where it was stalled")
   342  
   343  			cmd.Process.Signal(syscall.SIGQUIT)
   344  		}
   345  	}()
   346  
   347  	err := cmd.Run()
   348  	close(done)
   349  
   350  	cmdResult := &CmdResult{
   351  		OriginalCommand: cmdString,
   352  		Stdout:          stdOut.String(),
   353  		Stderr:          stdErr.String(),
   354  		ExtraEnv:        extraEnv,
   355  		Cwd:             h.cwd,
   356  		Input:           input,
   357  		History:         h.history,
   358  		ExitState:       cmd.ProcessState,
   359  		t:               h.t,
   360  	}
   361  
   362  	if err, ok := err.(*exec.ExitError); ok {
   363  		cmdResult.Success = false
   364  	} else if err != nil {
   365  		h.t.Fatalf("Failed to run cmd %v :  %v", args, err)
   366  	} else {
   367  		cmdResult.Success = true
   368  	}
   369  
   370  	return cmdResult
   371  }
   372  
   373  // Fn runs the Fn ClI with the specified arguments
   374  func (h *CLIHarness) Fn(args ...string) *CmdResult {
   375  	return h.FnWithInput("", args...)
   376  }
   377  
   378  //NewFuncName creates a valid function name and registers it for deletion
   379  func (h *CLIHarness) NewFuncName(appName string) string {
   380  	funcName := randString(8)
   381  	h.funcRefs = append(h.funcRefs, funcRef{appName, funcName})
   382  	return funcName
   383  }
   384  
   385  //NewTriggerName creates a valid trigger name and registers it for deletioneanup
   386  func (h *CLIHarness) NewTriggerName(appName, funcName string) string {
   387  	triggerName := randString(8)
   388  	h.triggerRefs = append(h.triggerRefs, triggerRef{appName, funcName, triggerName})
   389  	return triggerName
   390  }
   391  
   392  func (h *CLIHarness) relativeToTestDir(dir string) string {
   393  	absDir, err := filepath.Abs(path.Join(h.testDir, dir))
   394  	if err != nil {
   395  		h.t.Fatalf("Invalid path operation : %v", err)
   396  	}
   397  
   398  	if !strings.HasPrefix(absDir, h.testDir) {
   399  		h.t.Fatalf("Cannot change directory to %s out of test directory %s", absDir, h.testDir)
   400  	}
   401  	return absDir
   402  }
   403  
   404  func (h *CLIHarness) relativeToCwd(dir string) string {
   405  	absDir, err := filepath.Abs(path.Join(h.cwd, dir))
   406  	if err != nil {
   407  		h.t.Fatalf("Invalid path operation : %v", err)
   408  	}
   409  
   410  	if !strings.HasPrefix(absDir, h.testDir) {
   411  		h.t.Fatalf("Cannot change directory to %s out of test directory %s", absDir, h.testDir)
   412  	}
   413  	return absDir
   414  }
   415  
   416  // Cd Changes the working directory for commands - passing "" resets this to the test directory
   417  // You cannot Cd out of the test directory
   418  func (h *CLIHarness) Cd(s string) {
   419  
   420  	if s == "" {
   421  		h.cwd = h.testDir
   422  	} else {
   423  		h.cwd = h.relativeToCwd(s)
   424  	}
   425  
   426  	h.pushHistoryf("cd %s", h.cwd)
   427  
   428  }
   429  func (h *CLIHarness) pushHistoryf(s string, args ...interface{}) {
   430  	//log.Printf(s, args...)
   431  	h.history = append(h.history, fmt.Sprintf(s, args...))
   432  
   433  }
   434  
   435  // MkDir creates a directory in the current cwd
   436  func (h *CLIHarness) MkDir(dir string) {
   437  	os.Mkdir(h.relativeToCwd(dir), 0777)
   438  
   439  }
   440  
   441  func (h *CLIHarness) Exec(name string, args ...string) error {
   442  	cmd := exec.Command(name, args...)
   443  	cmd.Dir = h.cwd
   444  	err := cmd.Run()
   445  	//	out, err := cmd.CombinedOutput()
   446  	//	fmt.Printf("STDOUT: %s", out)
   447  	//	fmt.Printf("STDERR: %s", err)
   448  	return err
   449  }
   450  
   451  //FileAppend appends val to  an existing file
   452  func (h *CLIHarness) FileAppend(file string, val string) {
   453  	filePath := h.relativeToCwd(file)
   454  	fileV, err := ioutil.ReadFile(filePath)
   455  
   456  	if err != nil {
   457  		h.t.Fatalf("Failed to read file %s: %v", file, err)
   458  	}
   459  
   460  	newV := string(fileV) + val
   461  	err = ioutil.WriteFile(filePath, []byte(newV), 0555)
   462  	if err != nil {
   463  		h.t.Fatalf("Failed to write appended file %s", err)
   464  	}
   465  
   466  	h.pushHistoryf("echo '%s' >> %s", val, filePath)
   467  
   468  }
   469  
   470  // GetFile dumps the content of a file (relative to the  CWD)
   471  func (h *CLIHarness) GetFile(s string) string {
   472  	v, err := ioutil.ReadFile(h.relativeToCwd(s))
   473  	if err != nil {
   474  		h.t.Fatalf("File %s is not readable %v", s, err)
   475  	}
   476  	return string(v)
   477  }
   478  
   479  func (h *CLIHarness) RemoveFile(s string) error {
   480  	return os.Remove(h.relativeToCwd(s))
   481  }
   482  
   483  func (h *CLIHarness) GetYamlFile(s string) common.FuncFileV20180708 {
   484  	b, err := ioutil.ReadFile(h.relativeToCwd(s))
   485  	if err != nil {
   486  		h.t.Fatalf("could not open func file for parsing. Error: %v", err)
   487  	}
   488  	var ff common.FuncFileV20180708
   489  	err = yaml.Unmarshal(b, &ff)
   490  
   491  	return ff
   492  }
   493  
   494  func (h *CLIHarness) WriteYamlFile(s string, ff common.FuncFileV20180708) {
   495  
   496  	ffContent, _ := yaml.Marshal(ff)
   497  	h.WithFile(s, string(ffContent), 0600)
   498  
   499  }
   500  
   501  func (h *CLIHarness) WriteYamlFileV1(s string, ff common.FuncFile) {
   502  
   503  	ffContent, _ := yaml.Marshal(ff)
   504  	h.WithFile(s, string(ffContent), 0600)
   505  
   506  }
   507  
   508  func (cr *CmdResult) AssertStdoutContainsJSON(query []string, value interface{}) {
   509  	routeObj := map[string]interface{}{}
   510  	err := json.Unmarshal([]byte(cr.Stdout), &routeObj)
   511  	if err != nil {
   512  		log.Fatalf("Failed to parse routes inspect as JSON %v, %v", err, cr)
   513  	}
   514  
   515  	q := jsonq.NewQuery(routeObj)
   516  
   517  	val, err := q.Interface(query...)
   518  	if err != nil {
   519  		log.Fatalf("Failed to find path %v in json body %v", query, cr.Stdout)
   520  	}
   521  
   522  	if val != value {
   523  		log.Fatalf("Expected %s to be %v but was %s, %v", strings.Join(query, "."), value, val, cr)
   524  	}
   525  }
   526  
   527  func (cr *CmdResult) AssertStdoutMissingJSONPath(query []string) {
   528  	routeObj := map[string]interface{}{}
   529  	err := json.Unmarshal([]byte(cr.Stdout), &routeObj)
   530  	if err != nil {
   531  		log.Fatalf("Failed to parse routes inspect as JSON %v, %v", err, cr)
   532  	}
   533  
   534  	q := jsonq.NewQuery(routeObj)
   535  	_, err = q.Interface(query...)
   536  	if err == nil {
   537  		log.Fatalf("Found path %v in json body %v when it was supposed to be missing", query, cr.Stdout)
   538  	}
   539  }
   540  
   541  func (h *CLIHarness) CreateFuncfile(funcName, runtime string) *CLIHarness {
   542  	funcYaml := `version: 0.0.1
   543  name: ` + funcName + `
   544  runtime: ` + runtime + `
   545  entrypoint: ./func
   546  `
   547  
   548  	h.WithFile("func.yaml", funcYaml, 0644)
   549  	return h
   550  }
   551  
   552  func (h *CLIHarness) Docker(args ...string) *CmdResult {
   553  	stdOut := bytes.Buffer{}
   554  	stdErr := bytes.Buffer{}
   555  
   556  	cmd := exec.Command("docker", args...)
   557  	cmd.Stderr = &stdErr
   558  	cmd.Stdout = &stdOut
   559  
   560  	cmd.Dir = h.cwd
   561  	cmd.Env = os.Environ()
   562  
   563  	cmdString := "docker " + strings.Join(args, " ")
   564  
   565  	h.pushHistoryf("%s", cmdString)
   566  	done := make(chan interface{})
   567  	timer := time.NewTimer(commandTimeout)
   568  
   569  	// If the CLI stalls for more than commandTimeout we send a SIQQUIT which should result in a stack trace in stderr
   570  	go func() {
   571  		select {
   572  		case <-done:
   573  			return
   574  		case <-timer.C:
   575  			h.t.Errorf("Command timed out - killing docker with SIGQUIT - see STDERR log for stack trace of where it was stalled")
   576  
   577  			cmd.Process.Signal(syscall.SIGQUIT)
   578  		}
   579  	}()
   580  
   581  	err := cmd.Run()
   582  	close(done)
   583  
   584  	cmdResult := &CmdResult{
   585  		OriginalCommand: cmdString,
   586  		Stdout:          stdOut.String(),
   587  		Stderr:          stdErr.String(),
   588  		Cwd:             h.cwd,
   589  		History:         h.history,
   590  		ExitState:       cmd.ProcessState,
   591  		t:               h.t,
   592  	}
   593  
   594  	if err, ok := err.(*exec.ExitError); ok {
   595  		cmdResult.Success = false
   596  	} else if err != nil {
   597  		h.t.Fatalf("Failed to run cmd %v :  %v", args, err)
   598  	} else {
   599  		cmdResult.Success = true
   600  	}
   601  
   602  	return cmdResult
   603  
   604  }