github.com/GoogleCloudPlatform/testgrid@v0.0.174/util/gcs/read_test.go (about)

     1  /*
     2  Copyright 2018 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package gcs
    18  
    19  import (
    20  	"bytes"
    21  	"context"
    22  	"encoding/xml"
    23  	"errors"
    24  	"fmt"
    25  	"io"
    26  	"io/ioutil"
    27  	"net/url"
    28  	"reflect"
    29  	"sort"
    30  	"sync"
    31  	"testing"
    32  	"time"
    33  
    34  	"cloud.google.com/go/storage"
    35  	"github.com/GoogleCloudPlatform/testgrid/metadata"
    36  	"github.com/GoogleCloudPlatform/testgrid/metadata/junit"
    37  	"github.com/google/go-cmp/cmp"
    38  	"google.golang.org/api/iterator"
    39  	core "k8s.io/api/core/v1"
    40  )
    41  
    42  func podCondition(who core.PodConditionType, what core.ConditionStatus, why string) core.PodCondition {
    43  	return core.PodCondition{
    44  		Type:    who,
    45  		Status:  what,
    46  		Message: why,
    47  	}
    48  }
    49  
    50  func containerStatus(name string, ready, completed bool, exitCode int32) core.ContainerStatus {
    51  	status := core.ContainerStatus{
    52  		Name:  name,
    53  		Ready: ready,
    54  	}
    55  
    56  	if completed {
    57  		status.State.Terminated = &core.ContainerStateTerminated{ExitCode: exitCode}
    58  	}
    59  	return status
    60  }
    61  
    62  func containerWaiting(name string, msg string) core.ContainerStatus {
    63  	status := containerStatus(name, false, false, 0)
    64  	status.State.Waiting = &core.ContainerStateWaiting{Message: msg}
    65  	return status
    66  }
    67  
    68  func TestPodInfoSummarize(t *testing.T) {
    69  	cases := []struct {
    70  		name string
    71  		info PodInfo
    72  		pass bool
    73  		msg  string
    74  	}{
    75  		{
    76  			name: "basically works",
    77  			msg:  MissingPodInfo,
    78  		},
    79  		{
    80  			name: "passing pod works",
    81  			info: PodInfo{
    82  				Pod: &core.Pod{
    83  					Status: core.PodStatus{Phase: core.PodSucceeded},
    84  				},
    85  			},
    86  			pass: true,
    87  		},
    88  		{
    89  			// https://storage.googleapis.com/kubernetes-jenkins/logs/ci-kubernetes-e2e-gce-ubuntu1-k8sstable1-serial/1364737725537718272/podinfo.json
    90  			// Initialized, not ready/containersready with only test container
    91  			// no initcontainers
    92  			name: "non-pod-utils failure works",
    93  			info: PodInfo{
    94  				Pod: &core.Pod{
    95  					Status: core.PodStatus{
    96  						Phase: core.PodFailed,
    97  						Conditions: []core.PodCondition{
    98  							podCondition(core.PodScheduled, core.ConditionTrue, ""),
    99  							podCondition(core.PodInitialized, core.ConditionTrue, ""),
   100  							podCondition(core.PodReady, core.ConditionFalse, ""),
   101  						},
   102  						ContainerStatuses: []core.ContainerStatus{
   103  							containerStatus("test", false, true, 1),
   104  						},
   105  						InitContainerStatuses: []core.ContainerStatus{
   106  							{},
   107  						},
   108  					},
   109  				},
   110  			},
   111  			pass: true,
   112  			msg:  NoPodUtils,
   113  		},
   114  		{
   115  			// https://storage.googleapis.com/kubernetes-jenkins/pr-logs/pull/test-infra/21014/pull-test-infra-bazel/1364742867209162752/podinfo.json
   116  			// init'd, not ready/containersready with test sidecar
   117  			name: "normal failure works",
   118  			info: PodInfo{
   119  				Pod: &core.Pod{
   120  					Status: core.PodStatus{
   121  						Phase: core.PodFailed,
   122  						Conditions: []core.PodCondition{
   123  							podCondition(core.PodScheduled, core.ConditionTrue, ""),
   124  							podCondition(core.PodInitialized, core.ConditionTrue, ""),
   125  							podCondition(core.PodReady, core.ConditionFalse, ""),
   126  						},
   127  						ContainerStatuses: []core.ContainerStatus{
   128  							containerStatus("sidecar", false, true, 0),
   129  							containerStatus("test", false, true, 1),
   130  						},
   131  						InitContainerStatuses: []core.ContainerStatus{
   132  							containerStatus("init-upload", false, true, 0),
   133  							containerStatus("place-entrypoint", false, true, 0),
   134  							containerStatus("clonerefs", false, true, 0),
   135  						},
   136  					},
   137  				},
   138  			},
   139  			pass: true,
   140  		},
   141  		{
   142  			// https://storage.googleapis.com/kubernetes-jenkins/logs/ci-benchmark-scheduler-master/1364668262104698880/podinfo.json
   143  			// aka pending status, podscheduled false with message.
   144  			name: "detect scheduling failure",
   145  			info: PodInfo{
   146  				Pod: &core.Pod{
   147  					Status: core.PodStatus{
   148  						Phase: core.PodPending,
   149  						Conditions: []core.PodCondition{
   150  							podCondition(core.PodScheduled, core.ConditionFalse, "0/159 nodes available"),
   151  						},
   152  					},
   153  				},
   154  			},
   155  			msg: "pod did not schedule: 0/159 nodes available",
   156  		},
   157  		{
   158  			// TODO(fejta): find public example
   159  			// Initialized false, "message": "containers with incomplete status: [clonerefs initupload place-entrypoint]"
   160  			name: "detect initialization issue",
   161  			info: PodInfo{
   162  				Pod: &core.Pod{
   163  					Status: core.PodStatus{
   164  						Phase: core.PodFailed,
   165  						Conditions: []core.PodCondition{
   166  							podCondition(core.PodScheduled, core.ConditionTrue, ""),
   167  							podCondition(core.PodInitialized, core.ConditionFalse, "beep boop bop"),
   168  						},
   169  					},
   170  				},
   171  			},
   172  			msg: "pod could not initialize: beep boop bop",
   173  		},
   174  		{
   175  			// https://storage.googleapis.com/kubernetes-jenkins/logs/tf-minigo-periodic/1364608678237310976/podinfo.json
   176  			// failed to pull image
   177  			name: "detect image pull failure",
   178  			info: PodInfo{
   179  				Pod: &core.Pod{
   180  					Status: core.PodStatus{
   181  						Phase: core.PodFailed,
   182  						Conditions: []core.PodCondition{
   183  							podCondition(core.PodScheduled, core.ConditionTrue, ""),
   184  							podCondition(core.PodInitialized, core.ConditionTrue, ""),
   185  							podCondition(core.PodReady, core.ConditionFalse, ""),
   186  						},
   187  						ContainerStatuses: []core.ContainerStatus{
   188  							containerStatus("sidecar", false, true, 0),
   189  							containerWaiting("test", "failed to resolve image \"gcr.io/minigo-testing/minigo-prow-harness-v2:latest"),
   190  						},
   191  						InitContainerStatuses: []core.ContainerStatus{
   192  							containerStatus("init-upload", false, true, 0),
   193  							containerStatus("place-entrypoint", false, true, 0),
   194  							containerStatus("clonerefs", false, true, 0),
   195  						},
   196  					},
   197  				},
   198  			},
   199  			msg: "test still waiting: failed to resolve image \"gcr.io/minigo-testing/minigo-prow-harness-v2:latest",
   200  		},
   201  	}
   202  
   203  	for _, tc := range cases {
   204  		t.Run(tc.name, func(t *testing.T) {
   205  			pass, msg := tc.info.Summarize()
   206  			if pass != tc.pass {
   207  				t.Errorf("Summarize() got %t, want %t", pass, tc.pass)
   208  			}
   209  			if msg != tc.msg {
   210  				t.Errorf("Summarize() got %q, want %q", msg, tc.msg)
   211  			}
   212  		})
   213  	}
   214  }
   215  
   216  func subdir(prefix string) storage.ObjectAttrs {
   217  	return storage.ObjectAttrs{Prefix: prefix}
   218  }
   219  
   220  func link(path Path, name, other string) storage.ObjectAttrs {
   221  	return storage.ObjectAttrs{
   222  		Metadata: map[string]string{"x-goog-meta-link": other},
   223  		Name:     resolveOrDie(path, name).Object(),
   224  	}
   225  }
   226  
   227  func TestBuildJob(t *testing.T) {
   228  	cases := []struct {
   229  		path  string
   230  		build string
   231  		job   string
   232  	}{
   233  		{
   234  			path:  "gs://bucket/path/job/hello",
   235  			build: "hello",
   236  			job:   "job",
   237  		},
   238  		{
   239  			path:  "gs://bucket/path/job/hello/",
   240  			build: "hello",
   241  			job:   "job",
   242  		},
   243  	}
   244  
   245  	for _, tc := range cases {
   246  		t.Run(tc.path, func(t *testing.T) {
   247  			p, err := NewPath(tc.path)
   248  			if err != nil {
   249  				t.Fatalf("NewPath(%q) got unexpected error: %v", tc.path, err)
   250  			}
   251  			b := Build{Path: *p}
   252  			job, build := b.Job(), b.Build()
   253  			if job != tc.job {
   254  				t.Errorf("Job got %q want %q", job, tc.job)
   255  			}
   256  			if build != tc.build {
   257  				t.Errorf("Build got %q want %q", build, tc.build)
   258  			}
   259  		})
   260  	}
   261  }
   262  
   263  func TestOffsetHack(t *testing.T) {
   264  	cases := []struct {
   265  		name   string
   266  		input  string
   267  		output string
   268  		base   string
   269  	}{
   270  		{
   271  			name: "basically works",
   272  		},
   273  		{
   274  			name:   "normal prow builds work with trailing slash",
   275  			input:  "logs/ci-benchmark-scheduler/1364607429106470912/",
   276  			output: "logs/ci-benchmark-scheduler/1364607429106470912",
   277  			base:   "1364607429106470912",
   278  		},
   279  		{
   280  			name:   "normal prow builds work",
   281  			input:  "logs/ci-benchmark-scheduler/1364607429106470912",
   282  			output: "logs/ci-benchmark-scheduler/1364607429106470912",
   283  			base:   "1364607429106470912",
   284  		},
   285  		{
   286  			name:   "hack tot style with trailing slash",
   287  			input:  "logs/ci-benchmark-scheduler/10/",
   288  			output: "logs/ci-benchmark-scheduler/0",
   289  			base:   "10",
   290  		},
   291  		{
   292  			name:   "hack tot style",
   293  			input:  "logs/ci-benchmark-scheduler/10",
   294  			output: "logs/ci-benchmark-scheduler/0",
   295  			base:   "10",
   296  		},
   297  		{
   298  			name:   "non-numerical builds work",
   299  			input:  "logs/ci-benchmark-scheduler/fancy4u",
   300  			output: "logs/ci-benchmark-scheduler/fancy4u",
   301  			base:   "fancy4u",
   302  		},
   303  	}
   304  
   305  	for _, tc := range cases {
   306  		t.Run(tc.name, func(t *testing.T) {
   307  			output := tc.input
   308  			base := hackOffset(&output)
   309  			if output != tc.output {
   310  				t.Errorf("hackOffset(%q) became %q, want %q", tc.input, output, tc.output)
   311  			}
   312  			if base != tc.base {
   313  				t.Errorf("hackOffset(%q) returned %q, want %q", tc.input, base, tc.base)
   314  			}
   315  		})
   316  	}
   317  }
   318  
   319  func TestListBuilds(t *testing.T) {
   320  	path := newPathOrDie("gs://bucket/path/to/build/")
   321  	cases := []struct {
   322  		name     string
   323  		ctx      context.Context
   324  		iterator fakeIterator
   325  		offset   *Path
   326  
   327  		expected []Build
   328  		err      bool
   329  	}{
   330  		{
   331  			name: "basically works",
   332  		},
   333  		{
   334  			name: "multiple paths",
   335  			iterator: fakeIterator{
   336  				objects: []storage.ObjectAttrs{
   337  					subdir(resolveOrDie(path, "hello").Object()),
   338  					subdir(resolveOrDie(path, "world").Object()),
   339  				},
   340  			},
   341  			expected: []Build{
   342  				{
   343  					Path:     resolveOrDie(path, "world"),
   344  					baseName: "world",
   345  				},
   346  				{
   347  					Path:     newPathOrDie("gs://bucket/path/to/build/hello"),
   348  					baseName: "hello",
   349  				},
   350  			},
   351  		},
   352  		{
   353  			name: "presubmit symlinks work correctly",
   354  			iterator: fakeIterator{
   355  				objects: []storage.ObjectAttrs{
   356  					link(path, "first", "gs://another-bucket/path/inside"),
   357  					link(path, "second", "gs://second-bucket/somewhere"),
   358  				},
   359  			},
   360  			expected: []Build{
   361  				{
   362  					Path:     newPathOrDie("gs://second-bucket/somewhere/"),
   363  					baseName: "second",
   364  				},
   365  				{
   366  					Path:     newPathOrDie("gs://another-bucket/path/inside/"),
   367  					baseName: "first",
   368  				},
   369  			},
   370  		},
   371  		{
   372  			name: "cancelled context returns error",
   373  			iterator: fakeIterator{
   374  				objects: []storage.ObjectAttrs{
   375  					subdir(resolveOrDie(path, "hello").Object()),
   376  					subdir(resolveOrDie(path, "world").Object()),
   377  				},
   378  			},
   379  			ctx: func() context.Context {
   380  				ctx, cancel := context.WithCancel(context.Background())
   381  				cancel()
   382  				return ctx
   383  			}(),
   384  			err: true,
   385  		},
   386  		{
   387  			name: "iteration error returns error",
   388  			iterator: fakeIterator{
   389  				objects: []storage.ObjectAttrs{
   390  					subdir(resolveOrDie(path, "hello").Object()),
   391  					subdir(resolveOrDie(path, "world").Object()),
   392  					subdir(resolveOrDie(path, "more").Object()),
   393  				},
   394  				err: 1,
   395  			},
   396  			err: true,
   397  		},
   398  		{
   399  			name: "listing latest builds works correctly",
   400  			iterator: fakeIterator{
   401  				objects: []storage.ObjectAttrs{
   402  					subdir(resolveOrDie(path, "hello").Object()),
   403  					subdir(resolveOrDie(path, "more").Object()),
   404  					subdir(resolveOrDie(path, "world").Object()),
   405  				},
   406  			},
   407  			offset: pResolveOrDie(path, "more"),
   408  			expected: []Build{
   409  				{
   410  					Path:     resolveOrDie(path, "world"),
   411  					baseName: "world",
   412  				},
   413  			},
   414  		},
   415  		{
   416  			name: "drop results naturally before, include results naturally after",
   417  			iterator: fakeIterator{
   418  				objects: []storage.ObjectAttrs{
   419  					subdir(resolveOrDie(path, "100").Object()),
   420  					subdir(resolveOrDie(path, "1000").Object()),
   421  					subdir(resolveOrDie(path, "1100").Object()),
   422  					subdir(resolveOrDie(path, "1200").Object()),
   423  					subdir(resolveOrDie(path, "200").Object()),
   424  					subdir(resolveOrDie(path, "300").Object()),
   425  					subdir(resolveOrDie(path, "400").Object()),
   426  					subdir(resolveOrDie(path, "500").Object()),
   427  					subdir(resolveOrDie(path, "600").Object()),
   428  					subdir(resolveOrDie(path, "700").Object()),
   429  					subdir(resolveOrDie(path, "800").Object()),
   430  					subdir(resolveOrDie(path, "900").Object()),
   431  				},
   432  			},
   433  			offset: pResolveOrDie(path, "500"),
   434  			expected: []Build{
   435  				{
   436  					Path:     resolveOrDie(path, "1200"),
   437  					baseName: "1200",
   438  				},
   439  				{
   440  					Path:     resolveOrDie(path, "1100"),
   441  					baseName: "1100",
   442  				},
   443  				{
   444  					Path:     resolveOrDie(path, "1000"),
   445  					baseName: "1000",
   446  				},
   447  				{
   448  					Path:     resolveOrDie(path, "900"),
   449  					baseName: "900",
   450  				},
   451  				{
   452  					Path:     resolveOrDie(path, "800"),
   453  					baseName: "800",
   454  				},
   455  				{
   456  					Path:     resolveOrDie(path, "700"),
   457  					baseName: "700",
   458  				},
   459  				{
   460  					Path:     resolveOrDie(path, "600"),
   461  					baseName: "600",
   462  				},
   463  			},
   464  		},
   465  		{
   466  			name: "listing latest builds handles numbers correctly",
   467  			iterator: fakeIterator{
   468  				objects: []storage.ObjectAttrs{
   469  					subdir(resolveOrDie(path, "hello100").Object()),
   470  					subdir(resolveOrDie(path, "hello101").Object()),
   471  					subdir(resolveOrDie(path, "hello2000").Object()),
   472  					subdir(resolveOrDie(path, "hello30").Object()),
   473  					subdir(resolveOrDie(path, "hello31").Object()),
   474  					subdir(resolveOrDie(path, "hello300").Object()),
   475  				},
   476  			},
   477  			offset: pResolveOrDie(path, "hello100"),
   478  			expected: []Build{
   479  				{
   480  					Path:     resolveOrDie(path, "hello2000"),
   481  					baseName: "hello2000",
   482  				},
   483  				{
   484  					Path:     resolveOrDie(path, "hello300"),
   485  					baseName: "hello300",
   486  				},
   487  				{
   488  					Path:     resolveOrDie(path, "hello101"),
   489  					baseName: "hello101",
   490  				},
   491  			},
   492  		},
   493  		{
   494  			name: "listing latest presubmit symlinks handles numbers",
   495  			iterator: fakeIterator{
   496  				objects: []storage.ObjectAttrs{
   497  					link(path, "100", "gs://another-bucket/path/inside/100"),
   498  					link(path, "101", "gs://second-bucket/somewhere/101"),
   499  					link(path, "202", "gs://third-bucket/else/202"),
   500  					link(path, "2004", "gs://third-bucket/else/2004"),
   501  					link(path, "30", "gs://third-bucket/else/30"),
   502  					link(path, "303", "gs://third-bucket/else/303"),
   503  				},
   504  			},
   505  			offset: pResolveOrDie(path, "100"),
   506  			expected: []Build{
   507  				{
   508  					Path:     newPathOrDie("gs://third-bucket/else/2004/"),
   509  					baseName: "2004",
   510  				},
   511  				{
   512  					Path:     newPathOrDie("gs://third-bucket/else/303/"),
   513  					baseName: "303",
   514  				},
   515  				{
   516  					Path:     newPathOrDie("gs://third-bucket/else/202/"),
   517  					baseName: "202",
   518  				},
   519  				{
   520  					Path:     newPathOrDie("gs://second-bucket/somewhere/101/"),
   521  					baseName: "101",
   522  				},
   523  			},
   524  		},
   525  		{
   526  			name: "listing latest presubmit symlinks work correctly",
   527  			iterator: fakeIterator{
   528  				objects: []storage.ObjectAttrs{
   529  					link(path, "first", "gs://another-bucket/path/inside"),
   530  					link(path, "second", "gs://second-bucket/somewhere"),
   531  					link(path, "third", "gs://third-bucket/else"),
   532  				},
   533  			},
   534  			offset: pResolveOrDie(path, "second"),
   535  			expected: []Build{
   536  				{
   537  					Path:     newPathOrDie("gs://third-bucket/else/"),
   538  					baseName: "third",
   539  				},
   540  			},
   541  		},
   542  	}
   543  
   544  	for _, tc := range cases {
   545  		t.Run(tc.name, func(t *testing.T) {
   546  			fl := fakeLister{path: tc.iterator}
   547  			ctx := tc.ctx
   548  			if ctx == nil {
   549  				ctx = context.Background()
   550  			}
   551  			actual, err := ListBuilds(ctx, fl, path, tc.offset)
   552  			switch {
   553  			case err != nil:
   554  				if !tc.err {
   555  					t.Errorf("ListBuilds(): unexpected error: %v", err)
   556  				}
   557  			case tc.err:
   558  				t.Errorf("ListBuilds(): failed to receive an error")
   559  			default:
   560  				if diff := cmp.Diff(actual, tc.expected, cmp.AllowUnexported(Build{}, Path{})); diff != "" {
   561  					t.Errorf("ListBuilds(): got unexpected diff (-have, +want):\n%s", diff)
   562  				}
   563  			}
   564  		})
   565  	}
   566  }
   567  
   568  func TestReadLink(t *testing.T) {
   569  	cases := []struct {
   570  		name     string
   571  		meta     map[string]string
   572  		expected string
   573  	}{
   574  		{
   575  			name: "basically works",
   576  			meta: map[string]string{},
   577  		},
   578  		{
   579  			name: "find link",
   580  			meta: map[string]string{
   581  				"link": "foo",
   582  			},
   583  			expected: "foo",
   584  		},
   585  		{
   586  			name: "find x-goog-meta-link",
   587  			meta: map[string]string{
   588  				"x-goog-meta-link": "foo",
   589  			},
   590  			expected: "foo",
   591  		},
   592  		{
   593  			name: "ignore random",
   594  			meta: map[string]string{
   595  				"x-random-link": "foo",
   596  			},
   597  		},
   598  		{
   599  			name: "prefer x-goog-meta-link",
   600  			meta: map[string]string{
   601  				"x-goog-meta-link": "yes",
   602  				"link":             "no",
   603  			},
   604  			expected: "yes",
   605  		},
   606  	}
   607  
   608  	for _, tc := range cases {
   609  		t.Run(tc.name, func(t *testing.T) {
   610  			var oa storage.ObjectAttrs
   611  			oa.Metadata = tc.meta
   612  			if actual := readLink(&oa); actual != tc.expected {
   613  				t.Errorf("readLink(%v) got %q want %q", oa, actual, tc.expected)
   614  			}
   615  		})
   616  	}
   617  }
   618  
   619  func TestParseSuitesMeta(t *testing.T) {
   620  	cases := []struct {
   621  		name      string
   622  		input     string
   623  		context   string
   624  		timestamp string
   625  		thread    string
   626  		empty     bool
   627  	}{
   628  
   629  		{
   630  			name:  "not junit",
   631  			input: "./started.json",
   632  			empty: true,
   633  		},
   634  		{
   635  			name:  "forgot suffix",
   636  			input: "./junit",
   637  			empty: true,
   638  		},
   639  		{
   640  			name:  "basic",
   641  			input: "./junit.xml",
   642  		},
   643  		{
   644  			name:    "context",
   645  			input:   "./junit_hello world isn't-this exciting!.xml",
   646  			context: "hello world isn't-this exciting!",
   647  		},
   648  		{
   649  			name:    "numeric context",
   650  			input:   "./junit_12345.xml",
   651  			context: "12345",
   652  		},
   653  		{
   654  			name:    "context and thread",
   655  			input:   "./junit_context_12345.xml",
   656  			context: "context",
   657  			thread:  "12345",
   658  		},
   659  		{
   660  			name:      "context and timestamp",
   661  			input:     "./junit_context_20180102-1234.xml",
   662  			context:   "context",
   663  			timestamp: "20180102-1234",
   664  		},
   665  		{
   666  			name:      "context thread timestamp",
   667  			input:     "./junit_context_20180102-1234_5555.xml",
   668  			context:   "context",
   669  			timestamp: "20180102-1234",
   670  			thread:    "5555",
   671  		},
   672  		{
   673  			name:    "accept weird junit name",
   674  			input:   "./junit.e2e_suite.3.xml",
   675  			context: ".e2e_suite.3",
   676  		},
   677  		{
   678  			name:  "bazel format",
   679  			input: "./test.xml",
   680  		},
   681  	}
   682  
   683  	for _, tc := range cases {
   684  		actual := parseSuitesMeta(tc.input)
   685  		switch {
   686  		case actual == nil && !tc.empty:
   687  			t.Errorf("%s: unexpected nil map", tc.name)
   688  		case actual != nil && tc.empty:
   689  			t.Errorf("%s: should not have returned a map: %v", tc.name, actual)
   690  		case actual != nil:
   691  			for k, expected := range map[string]string{
   692  				"Context":   tc.context,
   693  				"Thread":    tc.thread,
   694  				"Timestamp": tc.timestamp,
   695  			} {
   696  				if a, ok := actual[k]; !ok {
   697  					t.Errorf("%s: missing key %s", tc.name, k)
   698  				} else if a != expected {
   699  					t.Errorf("%s: %s actual %s != expected %s", tc.name, k, a, expected)
   700  				}
   701  			}
   702  		}
   703  	}
   704  
   705  }
   706  
   707  func TestReadJSON(t *testing.T) {
   708  	cases := []struct {
   709  		name     string
   710  		obj      *fakeObject
   711  		actual   interface{}
   712  		expected interface{}
   713  		is       error
   714  	}{
   715  		{
   716  			name:     "basically works",
   717  			obj:      &fakeObject{data: "{}"},
   718  			actual:   &Started{},
   719  			expected: &Started{},
   720  		},
   721  		{
   722  			name: "read a json object",
   723  			obj:  &fakeObject{data: "{\"hello\": 5}"},
   724  			actual: &struct {
   725  				Hello int `json:"hello"`
   726  			}{},
   727  			expected: &struct {
   728  				Hello int `json:"hello"`
   729  			}{5},
   730  		},
   731  		{
   732  			name: "ErrObjectNotExist on open returns an ErrObjectNotExist error",
   733  			is:   storage.ErrObjectNotExist,
   734  		},
   735  		{
   736  			name: "other open errors also error",
   737  			obj:  &fakeObject{openErr: errors.New("injected open error")},
   738  		},
   739  		{
   740  			name: "read error errors",
   741  			obj: &fakeObject{
   742  				data:    "{}",
   743  				readErr: errors.New("injected read error"),
   744  			},
   745  		},
   746  		{
   747  			name: "close error errors",
   748  			obj: &fakeObject{
   749  				data:     "{}",
   750  				closeErr: errors.New("injected close error"),
   751  			},
   752  		},
   753  		{
   754  			name: "invalid json errors",
   755  			obj: &fakeObject{
   756  				data:     "{\"json\": \"hates trailing commas\",}",
   757  				closeErr: errors.New("injected close error"),
   758  			},
   759  		},
   760  	}
   761  
   762  	path := newPathOrDie("gs://bucket/path/to/something")
   763  	for _, tc := range cases {
   764  		t.Run(tc.name, func(t *testing.T) {
   765  			fo := fakeOpener{}
   766  			if tc.obj != nil {
   767  				fo[path] = *tc.obj
   768  			}
   769  			err := readJSON(context.Background(), fo, path, tc.actual)
   770  			switch {
   771  			case err != nil:
   772  				if tc.expected != nil {
   773  					t.Errorf("unexpected error: %v", err)
   774  				}
   775  				if tc.is != nil && !errors.Is(err, tc.is) {
   776  					t.Errorf("bad error: %v, wanted %v", err, tc.is)
   777  				}
   778  			case tc.expected == nil:
   779  				t.Error("failed to receive expected error")
   780  			default:
   781  				if !reflect.DeepEqual(tc.actual, tc.expected) {
   782  					t.Errorf("got %v, want %v", tc.actual, tc.expected)
   783  				}
   784  			}
   785  		})
   786  	}
   787  }
   788  
   789  type fakeOpener map[Path]fakeObject
   790  
   791  func (fo fakeOpener) Open(ctx context.Context, path Path) (io.ReadCloser, *storage.ReaderObjectAttrs, error) {
   792  	o, ok := fo[path]
   793  	if !ok {
   794  		return nil, nil, fmt.Errorf("wrap not exist: %w", storage.ErrObjectNotExist)
   795  	}
   796  	if o.openErr != nil {
   797  		return nil, nil, o.openErr
   798  	}
   799  	return ioutil.NopCloser(&fakeReader{
   800  		buf:      bytes.NewBufferString(o.data),
   801  		readErr:  o.readErr,
   802  		closeErr: o.closeErr,
   803  	}), o.attrs, nil
   804  }
   805  
   806  type fakeObject struct {
   807  	data     string
   808  	attrs    *storage.ReaderObjectAttrs
   809  	openErr  error
   810  	readErr  error
   811  	closeErr error
   812  }
   813  
   814  type fakeReader struct {
   815  	buf      *bytes.Buffer
   816  	readErr  error
   817  	closeErr error
   818  }
   819  
   820  func (fr *fakeReader) Read(p []byte) (int, error) {
   821  	if fr.readErr != nil {
   822  		return 0, fr.readErr
   823  	}
   824  	return fr.buf.Read(p)
   825  }
   826  
   827  func (fr *fakeReader) Close() error {
   828  	if fr.closeErr != nil {
   829  		return fr.closeErr
   830  	}
   831  	fr.readErr = errors.New("already closed")
   832  	fr.closeErr = fr.readErr
   833  	return nil
   834  }
   835  
   836  type fakeLister map[Path]fakeIterator
   837  
   838  func (fl fakeLister) Objects(ctx context.Context, path Path, _, offset string) Iterator {
   839  	f := fl[path]
   840  	f.ctx = ctx
   841  	f.offset = offset
   842  	return &f
   843  }
   844  
   845  type fakeIterator struct {
   846  	objects []storage.ObjectAttrs
   847  	idx     int
   848  	err     int // must be > 0
   849  	ctx     context.Context
   850  	offset  string
   851  }
   852  
   853  func (fi *fakeIterator) Next() (*storage.ObjectAttrs, error) {
   854  	if fi.ctx.Err() != nil {
   855  		return nil, fi.ctx.Err()
   856  	}
   857  	for fi.idx < len(fi.objects) {
   858  		if fi.offset == "" {
   859  			break
   860  		}
   861  		name, prefix := fi.objects[fi.idx].Name, fi.objects[fi.idx].Prefix
   862  		if (name == "" || name >= fi.offset) && (prefix == "" || prefix >= fi.offset) {
   863  			break
   864  		}
   865  		fi.idx++
   866  	}
   867  	if fi.idx >= len(fi.objects) {
   868  		return nil, iterator.Done
   869  	}
   870  	if fi.idx > 0 && fi.idx == fi.err {
   871  		return nil, errors.New("injected fakeIterator error")
   872  	}
   873  
   874  	o := fi.objects[fi.idx]
   875  	fi.idx++
   876  	return &o, nil
   877  }
   878  
   879  func TestStarted(t *testing.T) {
   880  	path := newPathOrDie("gs://bucket/path/")
   881  	started := resolveOrDie(path, "started.json")
   882  	cases := []struct {
   883  		name     string
   884  		ctx      context.Context
   885  		object   *fakeObject
   886  		expected *Started
   887  		checkErr error
   888  	}{
   889  		{
   890  			name:     "basically works",
   891  			object:   &fakeObject{data: "{}"},
   892  			expected: &Started{},
   893  		},
   894  		{
   895  			name:   "canceled context returns error",
   896  			object: &fakeObject{},
   897  			ctx: func() context.Context {
   898  				ctx, cancel := context.WithCancel(context.Background())
   899  				cancel()
   900  				return ctx
   901  			}(),
   902  		},
   903  		{
   904  			name: "all fields parsed",
   905  			object: &fakeObject{
   906  				data: `{
   907                      "timestamp": 1234,
   908                      "node": "machine",
   909                      "pull": "your leg",
   910                      "repos": {
   911                          "main": "deadbeef"
   912                      },
   913                      "repo-commit": "11111",
   914                      "metadata": {
   915                          "version": "fun",
   916                          "float": 1.2,
   917                          "object": {"yes": true}
   918                      }
   919                  }`,
   920  			},
   921  			expected: &Started{
   922  				Started: metadata.Started{
   923  					Timestamp: 1234,
   924  					Node:      "machine",
   925  					Pull:      "your leg",
   926  					Repos: map[string]string{
   927  						"main": "deadbeef",
   928  					},
   929  					RepoCommit: "11111",
   930  					Metadata: metadata.Metadata{
   931  						"version": "fun",
   932  						"float":   1.2,
   933  						"object": map[string]interface{}{
   934  							"yes": true,
   935  						},
   936  					},
   937  				},
   938  			},
   939  		},
   940  		{
   941  			name:     "missing object means pending",
   942  			expected: &Started{Pending: true},
   943  		},
   944  		{
   945  			name:   "read error returns an error",
   946  			object: &fakeObject{readErr: errors.New("injected read error")},
   947  		},
   948  	}
   949  
   950  	for _, tc := range cases {
   951  		t.Run(tc.name, func(t *testing.T) {
   952  			fo := fakeOpener{}
   953  			if tc.object != nil {
   954  				fo[started] = *tc.object
   955  			}
   956  			b := Build{Path: path}
   957  			if tc.ctx == nil {
   958  				tc.ctx = context.Background()
   959  			}
   960  			ctx, cancel := context.WithCancel(tc.ctx)
   961  			defer cancel()
   962  			actual, err := b.Started(ctx, fo)
   963  			switch {
   964  			case err != nil:
   965  				if tc.expected != nil {
   966  					t.Errorf("Started(): unexpected error: %v", err)
   967  				}
   968  			default:
   969  				if !reflect.DeepEqual(actual, tc.expected) {
   970  					t.Errorf("Started(): got %v, want %v", actual, tc.expected)
   971  				}
   972  			}
   973  
   974  		})
   975  	}
   976  }
   977  
   978  func TestFinished(t *testing.T) {
   979  	yes := true
   980  	path := newPathOrDie("gs://bucket/path/")
   981  	finished := resolveOrDie(path, "finished.json")
   982  	cases := []struct {
   983  		name     string
   984  		ctx      context.Context
   985  		object   *fakeObject
   986  		expected *Finished
   987  		checkErr error
   988  	}{
   989  		{
   990  			name:     "basically works",
   991  			object:   &fakeObject{data: "{}"},
   992  			expected: &Finished{},
   993  		},
   994  		{
   995  			name:   "canceled context returns error",
   996  			object: &fakeObject{},
   997  			ctx: func() context.Context {
   998  				ctx, cancel := context.WithCancel(context.Background())
   999  				cancel()
  1000  				return ctx
  1001  			}(),
  1002  		},
  1003  		{
  1004  			name: "all fields parsed",
  1005  			object: &fakeObject{
  1006  				data: `{
  1007                      "timestamp": 1234,
  1008                      "passed": true,
  1009                      "metadata": {
  1010                          "version": "fun",
  1011                          "float": 1.2,
  1012                          "object": {"yes": true}
  1013                      }
  1014                  }`,
  1015  			},
  1016  			expected: &Finished{
  1017  				Finished: metadata.Finished{
  1018  					Timestamp: func() *int64 {
  1019  						var out int64 = 1234
  1020  						return &out
  1021  					}(),
  1022  					Passed: &yes,
  1023  					Metadata: metadata.Metadata{
  1024  						"version": "fun",
  1025  						"float":   1.2,
  1026  						"object": map[string]interface{}{
  1027  							"yes": true,
  1028  						},
  1029  					},
  1030  				},
  1031  			},
  1032  		},
  1033  		{
  1034  			name:     "missing object means running",
  1035  			expected: &Finished{Running: true},
  1036  		},
  1037  		{
  1038  			name:   "read error returns an error",
  1039  			object: &fakeObject{readErr: errors.New("injected read error")},
  1040  		},
  1041  	}
  1042  
  1043  	for _, tc := range cases {
  1044  		t.Run(tc.name, func(t *testing.T) {
  1045  			fo := fakeOpener{}
  1046  			if tc.object != nil {
  1047  				fo[finished] = *tc.object
  1048  			}
  1049  			b := Build{Path: path}
  1050  			if tc.ctx == nil {
  1051  				tc.ctx = context.Background()
  1052  			}
  1053  			ctx, cancel := context.WithCancel(tc.ctx)
  1054  			defer cancel()
  1055  			actual, err := b.Finished(ctx, fo)
  1056  			switch {
  1057  			case err != nil:
  1058  				if tc.expected != nil {
  1059  					t.Errorf("Finished(): unexpected error: %v", err)
  1060  				}
  1061  			default:
  1062  				if !reflect.DeepEqual(actual, tc.expected) {
  1063  					t.Errorf("Finished(): got %v, want %v", actual, tc.expected)
  1064  				}
  1065  			}
  1066  
  1067  		})
  1068  	}
  1069  }
  1070  
  1071  func resolveOrDie(p Path, s string) Path {
  1072  	out, err := p.ResolveReference(&url.URL{Path: s})
  1073  	if err != nil {
  1074  		panic(fmt.Sprintf("%s - %s", p, err))
  1075  	}
  1076  	return *out
  1077  }
  1078  
  1079  func pResolveOrDie(p Path, s string) *Path {
  1080  	out := resolveOrDie(p, s)
  1081  	return &out
  1082  }
  1083  
  1084  func newPathOrDie(s string) Path {
  1085  	p, err := NewPath(s)
  1086  	if err != nil {
  1087  		panic(err)
  1088  	}
  1089  	return *p
  1090  }
  1091  
  1092  func TestReadSuites(t *testing.T) {
  1093  	path := newPathOrDie("gs://bucket/object")
  1094  	cases := []struct {
  1095  		name     string
  1096  		ctx      context.Context
  1097  		opener   fakeOpener
  1098  		expected *junit.Suites
  1099  		checkErr error
  1100  	}{
  1101  		{
  1102  			name: "basically works",
  1103  			opener: fakeOpener{
  1104  				path: {
  1105  					data: `<testsuites><testsuite><testcase name="foo"/></testsuite></testsuites>`,
  1106  				},
  1107  			},
  1108  			expected: &junit.Suites{
  1109  				XMLName: xml.Name{Local: "testsuites"},
  1110  				Suites: []junit.Suite{
  1111  					{
  1112  						XMLName: xml.Name{Local: "testsuite"},
  1113  						Results: []junit.Result{
  1114  							{
  1115  								Name: "foo",
  1116  							},
  1117  						},
  1118  					},
  1119  				},
  1120  			},
  1121  		},
  1122  		{
  1123  			name:     "not found returns not found error",
  1124  			checkErr: storage.ErrObjectNotExist,
  1125  		},
  1126  		{
  1127  			name: "invalid junit returns error",
  1128  			opener: fakeOpener{
  1129  				path: {data: `<wrong><type></type></wrong>`},
  1130  			},
  1131  		},
  1132  		{
  1133  			name: "reject large artifacts",
  1134  			opener: fakeOpener{
  1135  				path: {
  1136  					data:  `<testsuites><testsuite><testcase name="foo"/></testsuite></testsuites>`,
  1137  					attrs: &storage.ReaderObjectAttrs{Size: maxSize + 1},
  1138  				},
  1139  			},
  1140  		},
  1141  		{
  1142  			name: "read max size",
  1143  			opener: fakeOpener{
  1144  				path: {
  1145  					data:  `<testsuites><testsuite><testcase name="foo"/></testsuite></testsuites>`,
  1146  					attrs: &storage.ReaderObjectAttrs{Size: maxSize},
  1147  				},
  1148  			},
  1149  			expected: &junit.Suites{
  1150  				XMLName: xml.Name{Local: "testsuites"},
  1151  				Suites: []junit.Suite{
  1152  					{
  1153  						XMLName: xml.Name{Local: "testsuite"},
  1154  						Results: []junit.Result{
  1155  							{
  1156  								Name: "foo",
  1157  							},
  1158  						},
  1159  					},
  1160  				},
  1161  			},
  1162  		},
  1163  		{
  1164  			name: "read error returns error",
  1165  			opener: fakeOpener{
  1166  				path: {
  1167  					readErr: errors.New("injected read error"),
  1168  				},
  1169  			},
  1170  		},
  1171  	}
  1172  
  1173  	for _, tc := range cases {
  1174  		t.Run(tc.name, func(t *testing.T) {
  1175  			actual, err := readSuites(tc.ctx, tc.opener, path)
  1176  			switch {
  1177  			case err != nil:
  1178  				if tc.expected != nil {
  1179  					t.Errorf("readSuites(): unexpected error: %v", err)
  1180  				} else if tc.checkErr != nil && !errors.Is(err, tc.checkErr) {
  1181  					t.Errorf("readSuites(): bad error %v, wanted %v", err, tc.checkErr)
  1182  				}
  1183  			case tc.expected == nil:
  1184  				t.Error("readSuites(): failed to receive an error")
  1185  			default:
  1186  				if !reflect.DeepEqual(actual, tc.expected) {
  1187  					t.Errorf("readSuites(): got %v, want %v", actual, tc.expected)
  1188  				}
  1189  			}
  1190  		})
  1191  	}
  1192  }
  1193  
  1194  func TestArtifacts(t *testing.T) {
  1195  	path := newPathOrDie("gs://bucket/path/")
  1196  	cases := []struct {
  1197  		name     string
  1198  		ctx      context.Context
  1199  		iterator fakeIterator
  1200  		expected []string
  1201  		err      bool
  1202  	}{
  1203  		{
  1204  			name: "basically works",
  1205  		},
  1206  		{
  1207  			name: "cancelled context returns error",
  1208  			iterator: fakeIterator{
  1209  				objects: []storage.ObjectAttrs{
  1210  					{Name: "whatever"},
  1211  					{Name: "stuff"},
  1212  				},
  1213  			},
  1214  			ctx: func() context.Context {
  1215  				ctx, cancel := context.WithCancel(context.Background())
  1216  				cancel()
  1217  				return ctx
  1218  			}(),
  1219  			err: true,
  1220  		},
  1221  		{
  1222  			name: "iteration error returns error",
  1223  			iterator: fakeIterator{
  1224  				objects: []storage.ObjectAttrs{
  1225  					{Name: "hello"},
  1226  					{Name: "boom"},
  1227  					{Name: "world"},
  1228  				},
  1229  				err: 1,
  1230  			},
  1231  			err: true,
  1232  		},
  1233  		{
  1234  			name: "multiple objects work",
  1235  			iterator: fakeIterator{
  1236  				objects: []storage.ObjectAttrs{
  1237  					{Name: "hello"},
  1238  					{Name: "world"},
  1239  				},
  1240  			},
  1241  			expected: []string{"hello", "world"},
  1242  		},
  1243  	}
  1244  
  1245  	for _, tc := range cases {
  1246  		t.Run(tc.name, func(t *testing.T) {
  1247  			b := Build{
  1248  				Path: path,
  1249  			}
  1250  			var actual []string
  1251  			ch := make(chan string)
  1252  			var lock sync.Mutex
  1253  			lock.Lock()
  1254  			go func() {
  1255  				defer lock.Unlock()
  1256  				for a := range ch {
  1257  					actual = append(actual, a)
  1258  				}
  1259  			}()
  1260  			if tc.ctx == nil {
  1261  				tc.ctx = context.Background()
  1262  			}
  1263  			fl := fakeLister{path: tc.iterator}
  1264  			err := b.Artifacts(tc.ctx, fl, ch)
  1265  			close(ch)
  1266  			lock.Lock()
  1267  			switch {
  1268  			case err != nil:
  1269  				if !tc.err {
  1270  					t.Errorf("Artifacts(): unexpected error: %v", err)
  1271  				}
  1272  			case tc.err:
  1273  				t.Errorf("Artifacts(): failed to receive an error")
  1274  			default:
  1275  				if !reflect.DeepEqual(actual, tc.expected) {
  1276  					t.Errorf("Artifacts(): got %v, want %v", actual, tc.expected)
  1277  				}
  1278  			}
  1279  		})
  1280  	}
  1281  }
  1282  
  1283  func TestSuites(t *testing.T) {
  1284  	cases := []struct {
  1285  		name      string
  1286  		ctx       context.Context
  1287  		path      Path
  1288  		artifacts map[string]string
  1289  		max       int
  1290  
  1291  		expected []SuitesMeta
  1292  		err      bool
  1293  	}{
  1294  		{
  1295  			name: "basically works",
  1296  		},
  1297  		{
  1298  			name: "ignore random file",
  1299  			path: newPathOrDie("gs://where/whatever"),
  1300  			artifacts: map[string]string{
  1301  				"/something/ignore.txt":  "hello",
  1302  				"/something/ignore.json": "{}",
  1303  			},
  1304  		},
  1305  		{
  1306  			name: "support testsuite",
  1307  			path: newPathOrDie("gs://where/whatever"),
  1308  			artifacts: map[string]string{
  1309  				"/something/junit.xml": `<testsuites><testsuite><testcase name="foo"/></testsuite></testsuites>`,
  1310  			},
  1311  			expected: []SuitesMeta{
  1312  				{
  1313  					Suites: &junit.Suites{
  1314  						XMLName: xml.Name{Local: "testsuites"},
  1315  						Suites: []junit.Suite{
  1316  							{
  1317  								XMLName: xml.Name{Local: "testsuite"},
  1318  								Results: []junit.Result{
  1319  									{
  1320  										Name: "foo",
  1321  									},
  1322  								},
  1323  							},
  1324  						},
  1325  					},
  1326  					Metadata: parseSuitesMeta("/something/junit.xml"),
  1327  					Path:     "gs://where/something/junit.xml",
  1328  				},
  1329  			},
  1330  		},
  1331  		{
  1332  			name: "support testsuites",
  1333  			path: newPathOrDie("gs://where/whatever"),
  1334  			artifacts: map[string]string{
  1335  				"/something/junit.xml": `<testsuite><testcase name="foo"/></testsuite>`,
  1336  			},
  1337  			expected: []SuitesMeta{
  1338  				{
  1339  					Suites: &junit.Suites{
  1340  						Suites: []junit.Suite{
  1341  							{
  1342  								XMLName: xml.Name{Local: "testsuite"},
  1343  								Results: []junit.Result{
  1344  									{
  1345  										Name: "foo",
  1346  									},
  1347  								},
  1348  							},
  1349  						},
  1350  					},
  1351  					Metadata: parseSuitesMeta("/something/junit.xml"),
  1352  					Path:     "gs://where/something/junit.xml",
  1353  				},
  1354  			},
  1355  		},
  1356  		{
  1357  			name: "capture metadata",
  1358  			path: newPathOrDie("gs://where/whatever"),
  1359  			artifacts: map[string]string{
  1360  				"/something/junit_foo-context_20200708-1234_88.xml": `<testsuite><testcase name="foo"/></testsuite>`,
  1361  				"/something/junit_bar-context_20211234-0808_33.xml": `<testsuite><testcase name="bar"/></testsuite>`,
  1362  			},
  1363  			expected: []SuitesMeta{
  1364  				{
  1365  					Suites: &junit.Suites{
  1366  						Suites: []junit.Suite{
  1367  							{
  1368  								XMLName: xml.Name{Local: "testsuite"},
  1369  								Results: []junit.Result{
  1370  									{
  1371  										Name: "foo",
  1372  									},
  1373  								},
  1374  							},
  1375  						},
  1376  					},
  1377  					Metadata: parseSuitesMeta("/something/junit_foo-context_20200708-1234_88.xml"),
  1378  					Path:     "gs://where/something/junit_foo-context_20200708-1234_88.xml",
  1379  				},
  1380  				{
  1381  					Suites: &junit.Suites{
  1382  						Suites: []junit.Suite{
  1383  							{
  1384  								XMLName: xml.Name{Local: "testsuite"},
  1385  								Results: []junit.Result{
  1386  									{
  1387  										Name: "bar",
  1388  									},
  1389  								},
  1390  							},
  1391  						},
  1392  					},
  1393  					Metadata: parseSuitesMeta("/something/junit_bar-context_20211234-0808_33.xml"),
  1394  					Path:     "gs://where/something/junit_bar-context_20211234-0808_33.xml",
  1395  				},
  1396  			},
  1397  		},
  1398  		{
  1399  			name: "read suites error contains error",
  1400  			path: newPathOrDie("gs://where/whatever"),
  1401  			artifacts: map[string]string{
  1402  				"something/junit.xml": `<this is invalid json`,
  1403  			},
  1404  			expected: []SuitesMeta{
  1405  				{
  1406  					Metadata: parseSuitesMeta("something/junit.xml"),
  1407  					Err:      errors.New("boom"),
  1408  					Path:     "gs://where/something/junit.xml",
  1409  				},
  1410  			},
  1411  		},
  1412  		{
  1413  			name: "interrupted context returns error",
  1414  			ctx: func() context.Context {
  1415  				ctx, cancel := context.WithCancel(context.Background())
  1416  				cancel()
  1417  				return ctx
  1418  			}(),
  1419  			path: newPathOrDie("gs://where/whatever"),
  1420  			artifacts: map[string]string{
  1421  				"/something/junit_foo-context_20200708-1234_88.xml": `<testsuite><testcase name="foo"/></testsuite>`,
  1422  			},
  1423  			err: true,
  1424  		},
  1425  	}
  1426  
  1427  	for _, tc := range cases {
  1428  		t.Run(tc.name, func(t *testing.T) {
  1429  			fo := fakeOpener{}
  1430  			b := Build{Path: tc.path}
  1431  			for s, data := range tc.artifacts {
  1432  				fo[resolveOrDie(b.Path, s)] = fakeObject{data: data}
  1433  			}
  1434  
  1435  			parent, cancel := context.WithCancel(context.Background())
  1436  			defer cancel()
  1437  			if tc.ctx == nil {
  1438  				tc.ctx = parent
  1439  			}
  1440  			arts := make(chan string)
  1441  			go func() {
  1442  				defer close(arts)
  1443  				for a := range tc.artifacts {
  1444  					select {
  1445  					case arts <- a:
  1446  					case <-parent.Done():
  1447  						return
  1448  					}
  1449  				}
  1450  			}()
  1451  
  1452  			var actual []SuitesMeta
  1453  			suites := make(chan SuitesMeta)
  1454  			var lock sync.Mutex
  1455  			lock.Lock()
  1456  			go func() {
  1457  				defer lock.Unlock()
  1458  				time.Sleep(10 * time.Millisecond) // Allow time for ctx to expire
  1459  				for sm := range suites {
  1460  					actual = append(actual, sm)
  1461  				}
  1462  			}()
  1463  
  1464  			err := b.Suites(tc.ctx, fo, arts, suites, tc.max)
  1465  			close(suites)
  1466  			lock.Lock() // ensure actual is up to date
  1467  			defer lock.Unlock()
  1468  			// actual items appended in random order, so sort for consistency.
  1469  			sort.SliceStable(actual, func(i, j int) bool {
  1470  				return actual[i].Path < actual[j].Path
  1471  			})
  1472  			sort.SliceStable(tc.expected, func(i, j int) bool {
  1473  				return tc.expected[i].Path < tc.expected[j].Path
  1474  			})
  1475  			switch {
  1476  			case err != nil:
  1477  				if !tc.err {
  1478  					t.Errorf("Suites() unexpected error: %v", err)
  1479  				}
  1480  			case tc.err:
  1481  				t.Errorf("Suites() failed to receive expected error")
  1482  			default:
  1483  				cmpErrs := func(x, y error) bool {
  1484  					return (x == nil) == (y == nil)
  1485  				}
  1486  				if diff := cmp.Diff(tc.expected, actual, cmp.Comparer(cmpErrs)); diff != "" {
  1487  					t.Errorf("Suites() got unexpectec diff (-want +got):\n%s", diff)
  1488  				}
  1489  			}
  1490  		})
  1491  	}
  1492  }