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 }