[testrunner] Move FuchsiaTester initialization to a function

* Update SSHTester tests as well, since we added a close method.

Change-Id: I931e41838329ac688add2cbc81a6b84a18d674c8
diff --git a/cmd/testrunner/main.go b/cmd/testrunner/main.go
index 3910b9d..49ee0bc 100644
--- a/cmd/testrunner/main.go
+++ b/cmd/testrunner/main.go
@@ -12,6 +12,7 @@
 	"flag"
 	"fmt"
 	"io"
+	"io/ioutil"
 	"log"
 	"os"
 	"time"
@@ -30,9 +31,6 @@
 	// The username used to authenticate with the Fuchsia device.
 	sshUser = "fuchsia"
 
-	// The test output directory to create on the Fuchsia device.
-	fuchsiaOutputDir = "/data/infra/testrunner"
-
 	// The filename to use for log_listener's stdout captured during Fuchsia tests.
 	syslogStdoutFilename = "syslog-stdout.txt"
 
@@ -215,40 +213,15 @@
 }
 
 func runFuchsiaTests(tests []testsharder.Test, output *TestRunnerOutput, devCtx *botanist.DeviceContext) error {
-	if len(tests) == 0 {
-		return nil
-	} else if devCtx == nil {
+	if devCtx == nil {
 		return errors.New("no DeviceContext set; cannot execute fuchsia tests")
 	}
 
-	// Initialize the connection to the Fuchsia device.
-	sshClient, err := sshIntoNode(devCtx.Nodename, devCtx.SSHKey)
+	tester, err := NewFuchsiaTester(*devCtx)
 	if err != nil {
-		return fmt.Errorf("failed to connect to node %q: %v", devCtx.Nodename, err)
+		return fmt.Errorf("failed to initialize fuchsia tester: %v", err)
 	}
-	defer sshClient.Close()
-
-	fuchsiaTester := &FuchsiaTester{
-		remoteOutputDir: fuchsiaOutputDir,
-		delegate: &SSHTester{
-			client: sshClient,
-		},
-	}
-
-	// Record log_listener output. This goes into the output archive as "syslog.txt"
-	session, err := sshClient.NewSession()
-	if err != nil {
-		return err
-	}
-	defer session.Close()
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-
-	syslogStdout := new(bytes.Buffer)
-	syslogStderr := new(bytes.Buffer)
-	runner := &testrunner.SSHRunner{Session: session}
-	go runner.Run(ctx, []string{"bin/log_listener"}, syslogStdout, syslogStderr)
+	defer tester.Close()
 
 	// Ensure the syslog is always included in the output, even if tests fail.
 	// TODO(IN-824): Run fuchsia/linux/mac tests in go-routines and emit errors on channels.
@@ -257,16 +230,28 @@
 			return
 		}
 
-		if err := output.Tar.TarFile(syslogStdout.Bytes(), syslogStdoutFilename); err != nil {
+		stdout, stderr := tester.SyslogOutput()
+		stdoutBytes, err := ioutil.ReadAll(stdout)
+		if err != nil {
+			log.Println(err)
+			return
+		}
+		stderrBytes, err := ioutil.ReadAll(stderr)
+		if err != nil {
+			log.Println(err)
+			return
+		}
+
+		if err := output.Tar.TarFile(stdoutBytes, syslogStdoutFilename); err != nil {
 			log.Println(err)
 		}
 
-		if err := output.Tar.TarFile(syslogStderr.Bytes(), syslogStderrFilename); err != nil {
+		if err := output.Tar.TarFile(stderrBytes, syslogStderrFilename); err != nil {
 			log.Println(err)
 		}
 	}()
 
-	return runTests(tests, fuchsiaTester.Test, output)
+	return runTests(tests, tester.Test, output)
 }
 
 func runTests(tests []testsharder.Test, tester Tester, output *TestRunnerOutput) error {
diff --git a/cmd/testrunner/tester.go b/cmd/testrunner/tester.go
index f16720b..bc6b77f 100644
--- a/cmd/testrunner/tester.go
+++ b/cmd/testrunner/tester.go
@@ -5,15 +5,23 @@
 package main
 
 import (
+	"bytes"
 	"context"
+	"fmt"
 	"io"
 	"path"
 
+	"fuchsia.googlesource.com/tools/botanist"
 	"fuchsia.googlesource.com/tools/testrunner"
 	"fuchsia.googlesource.com/tools/testsharder"
 	"golang.org/x/crypto/ssh"
 )
 
+const (
+	// The test output directory to create on the Fuchsia device.
+	fuchsiaOutputDir = "/data/infra/testrunner"
+)
+
 // Tester is executes a Test.
 type Tester func(context.Context, testsharder.Test, io.Writer, io.Writer) error
 
@@ -40,11 +48,20 @@
 }
 
 // SSHTester executes tests over an SSH connection. It assumes the test.Command
-// contains the command line to execute on the remote machine.
+// contains the command line to execute on the remote machine. The caller should Close() the
+// tester when finished. Once closed, this object can no longer be used.
 type SSHTester struct {
 	client *ssh.Client
 }
 
+func NewSSHTester(devCtx botanist.DeviceContext) (*SSHTester, error) {
+	client, err := sshIntoNode(devCtx.Nodename, devCtx.SSHKey)
+	if err != nil {
+		return nil, fmt.Errorf("failed to connect to node %q: %v", devCtx.Nodename, err)
+	}
+	return &SSHTester{client: client}, nil
+}
+
 func (t *SSHTester) Test(ctx context.Context, test testsharder.Test, stdout io.Writer, stderr io.Writer) error {
 	session, err := t.client.NewSession()
 	if err != nil {
@@ -56,6 +73,15 @@
 	return runner.Run(ctx, test.Command, stdout, stderr)
 }
 
+// Close stops this SSHTester.  The underlying SSH connection is terminated.  The object
+// is no longer usable after calling this method.
+func (t *SSHTester) Close() error {
+	return t.client.Close()
+}
+
+// FuchsiaTester executes tests on remote Fuchsia devices. The caller should Close() the
+// tester when finished. Once closed, this object can no longer be used.
+//
 // This is a hack. We have to run Fuchsia tests using `runtests` on the remote device
 // because there are many ways to execute Fuchsia tests and runtests already does this
 // correctly. This wrapper around SSHTester is meant to keep SSHTester free of OS-specific
@@ -63,6 +89,28 @@
 type FuchsiaTester struct {
 	remoteOutputDir string
 	delegate        *SSHTester
+	remoteSyslog    *remoteSyslog
+}
+
+// NewFuchsiaTester creates a FuchsiaTester object and starts a log_listener process on
+// the remote device. The log_listener output can be read from SysLogOutput().
+func NewFuchsiaTester(devCtx botanist.DeviceContext) (*FuchsiaTester, error) {
+	delegate, err := NewSSHTester(devCtx)
+	if err != nil {
+		return nil, err
+	}
+
+	tester := &FuchsiaTester{
+		remoteOutputDir: fuchsiaOutputDir,
+		delegate:        delegate,
+	}
+
+	tester.remoteSyslog, err = newRemoteSyslog(context.Background(), delegate.client)
+	if err != nil {
+		return nil, fmt.Errorf("failed to start remote log_listener process: %v", err)
+	}
+
+	return tester, nil
 }
 
 func (t *FuchsiaTester) Test(ctx context.Context, test testsharder.Test, stdout io.Writer, stderr io.Writer) error {
@@ -70,3 +118,60 @@
 	test.Command = []string{"runtests", "-t", name, "-o", t.remoteOutputDir + "runtests"}
 	return t.delegate.Test(ctx, test, stdout, stderr)
 }
+
+// SysLogOutput returns the stdout and stderr streams of the remote log_listener process.
+func (t *FuchsiaTester) SyslogOutput() (stdout, stderr *bytes.Reader) {
+	return t.remoteSyslog.stdout(), t.remoteSyslog.stderr()
+}
+
+// Close stops this FuchsiaTester.  The remote log_listener process is terminated along
+// with the underlying SSH connection.  The object is no longer usable after calling this
+// method.
+func (t *FuchsiaTester) Close() error {
+	if err := t.remoteSyslog.stop(); err != nil {
+		return fmt.Errorf("failed to terminate remote log_listener: %v", err)
+	}
+	if err := t.delegate.Close(); err != nil {
+		return fmt.Errorf("failed to close delegate ssh runner: %v", err)
+	}
+	return nil
+}
+
+// A handle to a remote log_listener process on some Fuchsia device.
+type remoteSyslog struct {
+	stdoutBuf *bytes.Buffer
+	stderrBuf *bytes.Buffer
+	session   *ssh.Session
+}
+
+// Starts a remote process on the Fuchsia device to run log_listener. The output of the
+// process is written to the given stdout and stderr streams. The process will terminate
+// when the FuchsiaTester is Close().
+func newRemoteSyslog(ctx context.Context, client *ssh.Client) (*remoteSyslog, error) {
+	session, err := client.NewSession()
+	if err != nil {
+		return nil, err
+	}
+
+	sl := &remoteSyslog{
+		session:   session,
+		stdoutBuf: new(bytes.Buffer),
+		stderrBuf: new(bytes.Buffer),
+	}
+
+	runner := &testrunner.SSHRunner{Session: session}
+	go runner.Run(ctx, []string{"bin/log_listener"}, sl.stdoutBuf, sl.stderrBuf)
+	return sl, nil
+}
+
+func (sl *remoteSyslog) stdout() *bytes.Reader {
+	return bytes.NewReader(sl.stdoutBuf.Bytes())
+}
+
+func (sl *remoteSyslog) stderr() *bytes.Reader {
+	return bytes.NewReader(sl.stderrBuf.Bytes())
+}
+
+func (sl *remoteSyslog) stop() error {
+	return sl.session.Close()
+}
diff --git a/cmd/testrunner/tester_test.go b/cmd/testrunner/tester_test.go
index b40de24..b152c4e 100644
--- a/cmd/testrunner/tester_test.go
+++ b/cmd/testrunner/tester_test.go
@@ -15,21 +15,50 @@
 )
 
 func TestTester(t *testing.T) {
-	tester := &SubprocessTester{}
-	cases := []testCase{
+	cases := []struct {
+		name   string
+		test   testsharder.Test
+		stdout string
+		stderr string
+	}{
 		{
-			name:   "should run a command a local subprocess",
-			tester: tester.Test,
+			name: "should run a command a local subprocess",
 			test: testsharder.Test{
 				Name: "hello_world_test",
 				// Assumes that we're running on a Unix system.
 				Command: []string{"/bin/echo", "Hello world!"},
 			},
-			expectedOutput: "Hello world!",
+			stdout: "Hello world!",
 		},
 	}
 
-	runTestCases(t, cases)
+	for _, tt := range cases {
+		t.Run(tt.name, func(t *testing.T) {
+			tester := &SubprocessTester{}
+
+			stdout := new(bytes.Buffer)
+			stderr := new(bytes.Buffer)
+
+			if err := tester.Test(context.Background(), tt.test, stdout, stderr); err != nil {
+				t.Errorf("failed to exected test %q: %v", tt.test.Name, err)
+				return
+			}
+
+			// Compare stdout
+			actual := strings.TrimSpace(stdout.String())
+			expected := tt.stdout
+			if actual != expected {
+				t.Errorf("got stdout %q but wanted %q", actual, expected)
+			}
+
+			// Compare stderr
+			actual = strings.TrimSpace(stderr.String())
+			expected = tt.stderr
+			if actual != expected {
+				t.Errorf("got stderr %q but wanted %q", actual, expected)
+			}
+		})
+	}
 }
 
 // Verifies that SSHTester can execute tests on a remote device. These tests are
@@ -44,57 +73,73 @@
 		t.Fatal(err)
 	}
 
-	client, err := sshIntoNode(devCtx.Nodename, devCtx.SSHKey)
-	if err != nil {
-		t.Fatalf("failed to connect to node '%s': %v", devCtx.Nodename, err)
-	}
-
-	tester := &SSHTester{client: client}
-	cases := []testCase{
+	cases := []struct {
+		name   string
+		tests  []testsharder.Test
+		stdout string
+		stderr string
+	}{
 		{
-			name:   "should run a command over SSH",
-			tester: tester.Test,
-			test: testsharder.Test{
-				Name: "hello_world_test",
-				// Just 'echo' and not '/bin/echo' because this assumes we're running on
-				// Fuchsia.
-				Command: []string{"echo", "Hello world!"},
+			name: "should run a command over SSH",
+			tests: []testsharder.Test{
+				{
+					Name: "hello_world_test",
+					// Just 'echo' and not '/bin/echo' because this assumes we're running on
+					// Fuchsia.
+					Command: []string{"echo", "Hello world!"},
+				},
 			},
-			expectedOutput: "Hello world!",
+			stdout: "Hello world!",
 		},
 		{
-			name:   "should run successive commands over SSH",
-			tester: tester.Test,
-			test: testsharder.Test{
-				Name:    "hello_again_test",
-				Command: []string{"echo", "Hello again!"},
+			name: "should run successive commands over SSH",
+			tests: []testsharder.Test{
+				{
+					Name:    "test_1",
+					Command: []string{"echo", "this is test 1"},
+				},
+				{
+					Name:    "test_2",
+					Command: []string{"echo", "this is test 2"},
+				},
 			},
-			expectedOutput: "Hello again!",
+			stdout: "this is test 1\nthis is test 2",
 		},
 	}
 
-	runTestCases(t, cases)
-}
-
-type testCase struct {
-	name           string
-	test           testsharder.Test
-	tester         Tester
-	expectedOutput string
-	expectError    bool
-}
-
-func runTestCases(t *testing.T, cases []testCase) {
 	for _, tt := range cases {
 		t.Run(tt.name, func(t *testing.T) {
-			output := new(bytes.Buffer)
-			err := tt.tester(context.Background(), tt.test, output, output)
-			if tt.expectError && err == nil {
-				t.Fatalf("%s: got nil, wanted error", tt.name)
-			} else if !tt.expectError && err != nil {
-				t.Fatalf("%s: got err '%v', wanted nil", tt.name, err)
-			} else if tt.expectedOutput != strings.TrimSpace(output.String()) {
-				t.Fatalf("%s: got output: '%s', want: '%s'", tt.name, output.String(), tt.expectedOutput)
+			tester, err := NewSSHTester(*devCtx)
+			if err != nil {
+				t.Errorf("failed to intialize tester: %v", err)
+				return
+			}
+
+			stdout := new(bytes.Buffer)
+			stderr := new(bytes.Buffer)
+			for _, test := range tt.tests {
+				if err := tester.Test(context.Background(), test, stdout, stderr); err != nil {
+					t.Error(err)
+					return
+				}
+			}
+
+			if err := tester.Close(); err != nil {
+				t.Fatalf("failed to close tester: %v", err)
+			}
+
+			// Compare stdout
+			actual := strings.TrimSpace(stdout.String())
+			expected := tt.stdout
+			if actual != expected {
+				t.Errorf("got stdout %q but wanted %q", actual, expected)
+			}
+
+			// Compare stderr
+			actual = strings.TrimSpace(stderr.String())
+			expected = tt.stderr
+			if actual != expected {
+				t.Errorf("got stderr %q but wanted %q", actual, expected)
 			}
 		})
 	}