github.com/rigado/snapd@v2.42.5-go-mod+incompatible/testutil/exec.go (about) 1 // -*- Mode: Go; indent-tabs-mode: t -*- 2 3 /* 4 * Copyright (C) 2016 Canonical Ltd 5 * 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License version 3 as 8 * published by the Free Software Foundation. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License 16 * along with this program. If not, see <http://www.gnu.org/licenses/>. 17 * 18 */ 19 20 package testutil 21 22 import ( 23 "bytes" 24 "fmt" 25 "io" 26 "io/ioutil" 27 "os" 28 "os/exec" 29 "path" 30 "path/filepath" 31 "strings" 32 "sync" 33 34 "gopkg.in/check.v1" 35 ) 36 37 var shellcheckPath string 38 39 func init() { 40 if p, err := exec.LookPath("shellcheck"); err == nil { 41 shellcheckPath = p 42 } 43 } 44 45 var ( 46 shellchecked = make(map[string]bool, 16) 47 shellcheckedMu sync.Mutex 48 ) 49 50 func shellcheckSeenAlready(script string) bool { 51 shellcheckedMu.Lock() 52 defer shellcheckedMu.Unlock() 53 if shellchecked[script] { 54 return true 55 } 56 shellchecked[script] = true 57 return false 58 } 59 60 func maybeShellcheck(c *check.C, script string, wholeScript io.Reader) { 61 // MockCommand is used sometimes in SetUptTest, so it adds up 62 // even for the empty script, don't recheck the essentially same 63 // thing again and again! 64 if shellcheckSeenAlready(script) { 65 return 66 } 67 c.Logf("using shellcheck: %q", shellcheckPath) 68 if shellcheckPath == "" { 69 // no shellcheck, nothing to do 70 return 71 } 72 cmd := exec.Command(shellcheckPath, "-s", "bash", "-") 73 cmd.Stdin = wholeScript 74 out, err := cmd.CombinedOutput() 75 c.Check(err, check.IsNil, check.Commentf("shellcheck failed:\n%s", string(out))) 76 } 77 78 // MockCmd allows mocking commands for testing. 79 type MockCmd struct { 80 binDir string 81 exeFile string 82 logFile string 83 } 84 85 // The top of the script generate the output to capture the 86 // command that was run and the arguments used. To support 87 // mocking commands that need "\n" in their args (like zenity) 88 // we use the following convention: 89 // - generate \0 to separate args 90 // - generate \0\0 to separate commands 91 var scriptTpl = `#!/bin/bash 92 printf "%%s" "$(basename "$0")" >> %[1]q 93 printf '\0' >> %[1]q 94 95 for arg in "$@"; do 96 printf "%%s" "$arg" >> %[1]q 97 printf '\0' >> %[1]q 98 done 99 100 printf '\0' >> %[1]q 101 %s 102 ` 103 104 // MockCommand adds a mocked command. If the basename argument is a command 105 // it is added to PATH. If it is an absolute path it is just created there. 106 // the caller is responsible for the cleanup in this case. 107 // 108 // The command logs all invocations to a dedicated log file. If script is 109 // non-empty then it is used as is and the caller is responsible for how the 110 // script behaves (exit code and any extra behavior). If script is empty then 111 // the command exits successfully without any other side-effect. 112 func MockCommand(c *check.C, basename, script string) *MockCmd { 113 var wholeScript bytes.Buffer 114 var binDir, exeFile, logFile string 115 if filepath.IsAbs(basename) { 116 binDir = filepath.Dir(basename) 117 exeFile = basename 118 logFile = basename + ".log" 119 } else { 120 binDir = c.MkDir() 121 exeFile = path.Join(binDir, basename) 122 logFile = path.Join(binDir, basename+".log") 123 os.Setenv("PATH", binDir+":"+os.Getenv("PATH")) 124 } 125 fmt.Fprintf(&wholeScript, scriptTpl, logFile, script) 126 err := ioutil.WriteFile(exeFile, wholeScript.Bytes(), 0700) 127 if err != nil { 128 panic(err) 129 } 130 131 maybeShellcheck(c, script, &wholeScript) 132 133 return &MockCmd{binDir: binDir, exeFile: exeFile, logFile: logFile} 134 } 135 136 // Also mock this command, using the same bindir and log. 137 // Useful when you want to check the ordering of things. 138 func (cmd *MockCmd) Also(basename, script string) *MockCmd { 139 exeFile := path.Join(cmd.binDir, basename) 140 err := ioutil.WriteFile(exeFile, []byte(fmt.Sprintf(scriptTpl, cmd.logFile, script)), 0700) 141 if err != nil { 142 panic(err) 143 } 144 return &MockCmd{binDir: cmd.binDir, exeFile: exeFile, logFile: cmd.logFile} 145 } 146 147 // Restore removes the mocked command from PATH 148 func (cmd *MockCmd) Restore() { 149 entries := strings.Split(os.Getenv("PATH"), ":") 150 for i, entry := range entries { 151 if entry == cmd.binDir { 152 entries = append(entries[:i], entries[i+1:]...) 153 break 154 } 155 } 156 os.Setenv("PATH", strings.Join(entries, ":")) 157 } 158 159 // Calls returns a list of calls that were made to the mock command. 160 // of the form: 161 // [][]string{ 162 // {"cmd", "arg1", "arg2"}, // first invocation of "cmd" 163 // {"cmd", "arg1", "arg2"}, // second invocation of "cmd" 164 // } 165 func (cmd *MockCmd) Calls() [][]string { 166 raw, err := ioutil.ReadFile(cmd.logFile) 167 if os.IsNotExist(err) { 168 return nil 169 } 170 if err != nil { 171 panic(err) 172 } 173 logContent := strings.TrimSuffix(string(raw), "\000") 174 175 allCalls := [][]string{} 176 calls := strings.Split(logContent, "\000\000") 177 for _, call := range calls { 178 call = strings.TrimSuffix(call, "\000") 179 allCalls = append(allCalls, strings.Split(call, "\000")) 180 } 181 return allCalls 182 } 183 184 // ForgetCalls purges the list of calls made so far 185 func (cmd *MockCmd) ForgetCalls() { 186 err := os.Remove(cmd.logFile) 187 if os.IsNotExist(err) { 188 return 189 } 190 if err != nil { 191 panic(err) 192 } 193 } 194 195 // BinDir returns the location of the directory holding overridden commands. 196 func (cmd *MockCmd) BinDir() string { 197 return cmd.binDir 198 } 199 200 // Exe return the full path of the mock binary 201 func (cmd *MockCmd) Exe() string { 202 return filepath.Join(cmd.exeFile) 203 }