[testrunner] Partition and sort tests before execution

IN-824 #comment

Change-Id: Ibaa33c23ad2b2ccc1f07c96c847e87425b02a1c3
diff --git a/cmd/testrunner/main.go b/cmd/testrunner/main.go
index ded31f8..5e514b2 100644
--- a/cmd/testrunner/main.go
+++ b/cmd/testrunner/main.go
@@ -15,6 +15,7 @@
 	"log"
 	"os"
 	"path"
+	"sort"
 	"strings"
 	"time"
 
@@ -25,7 +26,6 @@
 )
 
 // TODO(IN-824): Produce a tar archive of all output files.
-// TODO(IN-824): Include log_listener output.
 
 const (
 	// Default amount of time to wait before failing to perform any IO action.
@@ -124,10 +124,43 @@
 		return fmt.Errorf("failed to unmarshal %s: %v", testsFilepath, err)
 	}
 
-	// Execute all tests.
-	summary, err := runTests(tests, fuchsiaTester.Test, RunTestInSubprocess, outputDir)
-	if err != nil {
-		return err
+	// Partition the tests into groups according to OS.
+	groups := groupTests(tests, func(test testsharder.Test) string {
+		sys := strings.ToLower(test.OS)
+		switch sys {
+		case "fuchsia", "linux", "mac":
+			return sys
+		}
+		return "unknown"
+	})
+
+	// Fail fast if any test cannot be run.
+	if unknownTests, ok := groups["unknown"]; ok {
+		return fmt.Errorf("could not determine the runtime system for following tests %v", unknownTests)
+	}
+
+	var summary *runtests.TestSummary
+
+	// Execute UNIX tests locally, assuming we're running in a UNIX environment.
+	var localTests []testsharder.Test
+	localTests = append(localTests, groups["linux"]...)
+	localTests = append(localTests, groups["mac"]...)
+	if len(localTests) > 0 {
+		details, err := runTests(localTests, RunTestInSubprocess, outputDir)
+		if err != nil {
+			return err
+		}
+		summary.Tests = append(summary.Tests, details...)
+	}
+
+	// Execute Fuchsia tests.
+	if fuchsiaTests, ok := groups["fuchsia"]; ok {
+		// TODO(IN-824): Record log_listener output.
+		details, err := runTests(fuchsiaTests, fuchsiaTester.Test, outputDir)
+		if err != nil {
+			return err
+		}
+		summary.Tests = append(summary.Tests, details...)
 	}
 
 	summaryFile, err := os.Create(path.Join(outputDir, "summary.json"))
@@ -140,6 +173,25 @@
 	return encoder.Encode(summary)
 }
 
+// groupTests splits a list of tests into named subgroups according to the names returned
+// by `name`.  Within any subgroup, the list of tests is sorted by test name.
+func groupTests(input []testsharder.Test, name func(testsharder.Test) string) map[string][]testsharder.Test {
+	tests := make([]testsharder.Test, len(input))
+	copy(tests, input)
+
+	sort.SliceStable(tests, func(i, j int) bool {
+		return tests[i].Name < tests[j].Name
+	})
+
+	output := make(map[string][]testsharder.Test)
+	for _, test := range tests {
+		group := name(test)
+		output[group] = append(output[group], test)
+	}
+
+	return output
+}
+
 func sshIntoNode(nodename, privateKeyPath string) (*ssh.Client, error) {
 	privateKey, err := ioutil.ReadFile(privateKeyPath)
 	if err != nil {
@@ -163,32 +215,20 @@
 	return botanist.SSHIntoNode(context.Background(), nodename, config)
 }
 
-func runTests(tests []testsharder.Test, fuchsia Tester, local Tester, outputDir string) (*runtests.TestSummary, error) {
-	// Execute all tests.
-	summary := new(runtests.TestSummary)
+func runTests(tests []testsharder.Test, tester Tester, outputDir string) ([]runtests.TestDetails, error) {
+	var output []runtests.TestDetails
 	for _, test := range tests {
-		var tester Tester
-		switch strings.ToLower(test.OS) {
-		case "fuchsia":
-			tester = fuchsia
-		case "linux", "mac":
-			tester = local
-		default:
-			log.Printf("cannot run '%s' on unknown OS '%s'", test.Name, test.OS)
-			continue
-		}
-
 		details, err := runTest(context.Background(), test, tester, outputDir)
 		if err != nil {
 			log.Println(err)
 		}
 
 		if details != nil {
-			summary.Tests = append(summary.Tests, *details)
+			output = append(output, *details)
 		}
 	}
 
-	return summary, nil
+	return output, nil
 }
 
 func runTest(ctx context.Context, test testsharder.Test, tester Tester, outputDir string) (*runtests.TestDetails, error) {
diff --git a/cmd/testrunner/main_test.go b/cmd/testrunner/main_test.go
new file mode 100644
index 0000000..e5e04bb
--- /dev/null
+++ b/cmd/testrunner/main_test.go
@@ -0,0 +1,59 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package main
+
+import (
+	"reflect"
+	"testing"
+
+	"fuchsia.googlesource.com/tools/testsharder"
+)
+
+func TestGroupTests(t *testing.T) {
+	tests := []struct {
+		name   string
+		input  []testsharder.Test
+		output map[string][]testsharder.Test
+	}{{
+		name: "should sort tests by name and partition them into subgroups",
+		input: []testsharder.Test{
+			{Name: "a", OS: "A"},
+			{Name: "c", OS: "C"},
+			{Name: "e", OS: "B"},
+			{Name: "d", OS: "B"},
+			{Name: "b", OS: "A"},
+		},
+		output: map[string][]testsharder.Test{
+			// Note that tests in each subgroup are sorted by name.
+			"A": []testsharder.Test{
+				{Name: "a", OS: "A"},
+				{Name: "b", OS: "A"},
+			},
+			"B": []testsharder.Test{
+				{Name: "d", OS: "B"},
+				{Name: "e", OS: "B"},
+			},
+			"C": []testsharder.Test{
+				{Name: "c", OS: "C"},
+			},
+		},
+	}, {
+		name:   "should produce an empty map when given empty input",
+		input:  []testsharder.Test{},
+		output: map[string][]testsharder.Test{},
+	}}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			output := groupTests(tt.input, func(test testsharder.Test) string {
+				return test.OS
+			})
+
+			if !reflect.DeepEqual(tt.output, output) {
+				t.Fatalf("got %v, want: '%v'", output, tt.output)
+			}
+		})
+	}
+}