github.com/saucelabs/saucectl@v0.175.1/internal/saucecloud/cloud_test.go (about)

     1  package saucecloud
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"os"
     8  	"os/exec"
     9  	"path/filepath"
    10  	"syscall"
    11  	"testing"
    12  	"time"
    13  
    14  	"github.com/saucelabs/saucectl/internal/job"
    15  	"github.com/saucelabs/saucectl/internal/junit"
    16  	"github.com/saucelabs/saucectl/internal/mocks"
    17  	"github.com/saucelabs/saucectl/internal/saucecloud/retry"
    18  	"github.com/saucelabs/saucectl/internal/saucecloud/zip"
    19  	"github.com/saucelabs/saucectl/internal/sauceignore"
    20  	"github.com/saucelabs/saucectl/internal/saucereport"
    21  	"github.com/stretchr/testify/assert"
    22  	"gotest.tools/v3/fs"
    23  )
    24  
    25  func TestSignalDetection(t *testing.T) {
    26  	r := CloudRunner{JobService: JobService{VDCStopper: &mocks.FakeJobStopper{}}}
    27  	assert.False(t, r.interrupted)
    28  	c := r.registerSkipSuitesOnSignal()
    29  	defer unregisterSignalCapture(c)
    30  
    31  	c <- syscall.SIGINT
    32  
    33  	deadline := time.NewTimer(3 * time.Second)
    34  	defer deadline.Stop()
    35  
    36  	// Wait for interrupt to be processed, as it happens asynchronously.
    37  	for {
    38  		select {
    39  		case <-deadline.C:
    40  			assert.True(t, r.interrupted)
    41  			return
    42  		default:
    43  			if r.interrupted {
    44  				return
    45  			}
    46  			time.Sleep(1 * time.Nanosecond) // allow context switch
    47  		}
    48  	}
    49  }
    50  
    51  func TestSignalDetectionExit(t *testing.T) {
    52  	if os.Getenv("FORCE_EXIT_TEST") == "1" {
    53  		r := CloudRunner{JobService: JobService{VDCStopper: &mocks.FakeJobStopper{}}}
    54  		assert.False(t, r.interrupted)
    55  		c := r.registerSkipSuitesOnSignal()
    56  		defer unregisterSignalCapture(c)
    57  
    58  		c <- syscall.SIGINT
    59  
    60  		deadline := time.NewTimer(3 * time.Second)
    61  		defer deadline.Stop()
    62  
    63  		// Wait for interrupt to be processed, as it happens asynchronously.
    64  	loop:
    65  		for {
    66  			select {
    67  			case <-deadline.C:
    68  				return
    69  			default:
    70  				if r.interrupted {
    71  					break loop
    72  				}
    73  				time.Sleep(1 * time.Nanosecond) // allow context switch
    74  			}
    75  		}
    76  
    77  		c <- syscall.SIGINT
    78  
    79  		// Process should get killed due to double interrupt. If this doesn't happen, the test will exit cleanly
    80  		// which will be caught by the original process of the test, which expects an exit code of 1.
    81  		time.Sleep(3 * time.Second)
    82  		return
    83  	}
    84  	cmd := exec.Command(os.Args[0], "-test.run=TestSignalDetectionExit")
    85  	cmd.Env = append(os.Environ(), "FORCE_EXIT_TEST=1")
    86  	err := cmd.Run()
    87  	if e, ok := err.(*exec.ExitError); ok && !e.Success() {
    88  		return
    89  	}
    90  	t.Fatalf("process ran with err %v, want exit status 1", err)
    91  }
    92  
    93  func TestSkippedRunJobs(t *testing.T) {
    94  	sut := CloudRunner{
    95  		JobService: JobService{
    96  			VDCStarter: &mocks.FakeJobStarter{
    97  				StartJobFn: func(ctx context.Context, opts job.StartOptions) (jobID string, isRDC bool, err error) {
    98  					return "fake-id", false, nil
    99  				},
   100  			},
   101  			VDCStopper: &mocks.FakeJobStopper{
   102  				StopJobFn: func(ctx context.Context, id string) (job.Job, error) {
   103  					return job.Job{
   104  						ID: "fake-id",
   105  					}, nil
   106  				},
   107  			},
   108  			VDCReader: &mocks.FakeJobReader{
   109  				PollJobFn: func(ctx context.Context, id string, interval time.Duration, timeout time.Duration) (job.Job, error) {
   110  					return job.Job{
   111  						ID:     "fake-id",
   112  						Passed: true,
   113  						Error:  "",
   114  						Status: job.StateComplete,
   115  					}, nil
   116  				},
   117  			},
   118  			VDCWriter: &mocks.FakeJobWriter{
   119  				UploadAssetFn: func(jobID string, fileName string, contentType string, content []byte) error {
   120  					return nil
   121  				},
   122  			},
   123  		},
   124  	}
   125  	sut.interrupted = true
   126  
   127  	_, skipped, err := sut.runJob(job.StartOptions{})
   128  
   129  	assert.True(t, skipped)
   130  	assert.Nil(t, err)
   131  }
   132  
   133  func TestRunJobsSkipped(t *testing.T) {
   134  	r := CloudRunner{}
   135  	r.interrupted = true
   136  
   137  	opts := make(chan job.StartOptions)
   138  	results := make(chan result)
   139  
   140  	go r.runJobs(opts, results)
   141  	opts <- job.StartOptions{}
   142  	close(opts)
   143  	res := <-results
   144  	assert.Nil(t, res.err)
   145  	assert.True(t, res.skipped)
   146  }
   147  
   148  func TestRunJobTimeout(t *testing.T) {
   149  	r := CloudRunner{
   150  		JobService: JobService{
   151  			VDCStarter: &mocks.FakeJobStarter{
   152  				StartJobFn: func(ctx context.Context, opts job.StartOptions) (jobID string, isRDC bool, err error) {
   153  					return "1", false, nil
   154  				},
   155  			},
   156  			VDCReader: &mocks.FakeJobReader{
   157  				PollJobFn: func(ctx context.Context, id string, interval time.Duration, timeout time.Duration) (job.Job, error) {
   158  					return job.Job{ID: id, TimedOut: true}, nil
   159  				},
   160  			},
   161  			VDCStopper: &mocks.FakeJobStopper{
   162  				StopJobFn: func(ctx context.Context, jobID string) (job.Job, error) {
   163  					return job.Job{ID: jobID}, nil
   164  				},
   165  			},
   166  			VDCWriter: &mocks.FakeJobWriter{UploadAssetFn: func(jobID string, fileName string, contentType string, content []byte) error {
   167  				return nil
   168  			}},
   169  		},
   170  	}
   171  
   172  	opts := make(chan job.StartOptions)
   173  	results := make(chan result)
   174  
   175  	go r.runJobs(opts, results)
   176  	opts <- job.StartOptions{
   177  		DisplayName: "dummy",
   178  		Timeout:     1,
   179  	}
   180  	close(opts)
   181  	res := <-results
   182  	assert.Error(t, res.err, "suite 'dummy' has reached timeout")
   183  	assert.True(t, res.job.TimedOut)
   184  }
   185  
   186  func TestRunJobRetries(t *testing.T) {
   187  	type testCase struct {
   188  		retries      int
   189  		wantAttempts int
   190  	}
   191  
   192  	tests := []testCase{
   193  		{
   194  			retries:      0,
   195  			wantAttempts: 1,
   196  		},
   197  		{
   198  			retries:      4,
   199  			wantAttempts: 5,
   200  		},
   201  	}
   202  	for _, tt := range tests {
   203  		r := CloudRunner{
   204  			Retrier: &retry.SauceReportRetrier{},
   205  			JobService: JobService{
   206  				VDCStarter: &mocks.FakeJobStarter{
   207  					StartJobFn: func(ctx context.Context, opts job.StartOptions) (jobID string, isRDC bool, err error) {
   208  						return "1", false, nil
   209  					},
   210  				},
   211  				VDCReader: &mocks.FakeJobReader{
   212  					PollJobFn: func(ctx context.Context, id string, interval time.Duration, timeout time.Duration) (job.Job, error) {
   213  						return job.Job{ID: id, Passed: false}, nil
   214  					},
   215  				},
   216  				VDCStopper: &mocks.FakeJobStopper{
   217  					StopJobFn: func(ctx context.Context, jobID string) (job.Job, error) {
   218  						return job.Job{ID: jobID}, nil
   219  					},
   220  				},
   221  				VDCWriter: &mocks.FakeJobWriter{UploadAssetFn: func(jobID string, fileName string, contentType string, content []byte) error {
   222  					return nil
   223  				}},
   224  			},
   225  		}
   226  
   227  		opts := make(chan job.StartOptions, tt.retries+1)
   228  		results := make(chan result)
   229  
   230  		go r.runJobs(opts, results)
   231  		opts <- job.StartOptions{
   232  			DisplayName: "retry job",
   233  			Retries:     tt.retries,
   234  		}
   235  		res := <-results
   236  		close(opts)
   237  		close(results)
   238  		assert.Equal(t, len(res.attempts), tt.wantAttempts)
   239  	}
   240  }
   241  
   242  func TestRunJobTimeoutRDC(t *testing.T) {
   243  	r := CloudRunner{
   244  		JobService: JobService{
   245  			RDCStarter: &mocks.FakeJobStarter{
   246  				StartJobFn: func(ctx context.Context, opts job.StartOptions) (jobID string, isRDC bool, err error) {
   247  					return "1", true, nil
   248  				},
   249  			},
   250  			RDCReader: &mocks.FakeJobReader{
   251  				PollJobFn: func(ctx context.Context, id string, interval time.Duration, timeout time.Duration) (job.Job, error) {
   252  					return job.Job{ID: id, TimedOut: true}, nil
   253  				},
   254  			},
   255  			RDCStopper: &mocks.FakeJobStopper{
   256  				StopJobFn: func(ctx context.Context, id string) (job.Job, error) {
   257  					return job.Job{ID: id, TimedOut: true}, nil
   258  				},
   259  			},
   260  		},
   261  	}
   262  
   263  	opts := make(chan job.StartOptions)
   264  	results := make(chan result)
   265  
   266  	go r.runJobs(opts, results)
   267  	opts <- job.StartOptions{
   268  		DisplayName: "dummy",
   269  		Timeout:     1,
   270  		RealDevice:  true,
   271  	}
   272  	close(opts)
   273  	res := <-results
   274  	assert.Error(t, res.err)
   275  	assert.True(t, res.job.TimedOut)
   276  }
   277  
   278  func TestCloudRunner_archiveNodeModules(t *testing.T) {
   279  	tempDir, err := os.MkdirTemp(os.TempDir(), "saucectl-app-payload-")
   280  	if err != nil {
   281  		t.Error(err)
   282  	}
   283  	defer os.RemoveAll(tempDir)
   284  
   285  	projectsDir := fs.NewDir(t, "project",
   286  		fs.WithDir("has-mods",
   287  			fs.WithDir("node_modules",
   288  				fs.WithDir("mod1",
   289  					fs.WithFile("package.json", "{}"),
   290  				),
   291  			),
   292  		),
   293  		fs.WithDir("no-mods"),
   294  		fs.WithDir("empty-mods",
   295  			fs.WithDir("node_modules"),
   296  		),
   297  	)
   298  	defer projectsDir.Remove()
   299  
   300  	wd, err := os.Getwd()
   301  	if err != nil {
   302  		t.Errorf("Failed to get the current working dir: %v", err)
   303  	}
   304  
   305  	if err := os.Chdir(projectsDir.Path()); err != nil {
   306  		t.Errorf("Failed to change the current working dir: %v", err)
   307  	}
   308  	defer func() {
   309  		if err := os.Chdir(wd); err != nil {
   310  			t.Errorf("Failed to change the current working dir back to original: %v", err)
   311  		}
   312  	}()
   313  
   314  	type fields struct {
   315  		NPMDependencies []string
   316  	}
   317  	type args struct {
   318  		tempDir string
   319  		rootDir string
   320  		matcher sauceignore.Matcher
   321  	}
   322  	tests := []struct {
   323  		name    string
   324  		fields  fields
   325  		args    args
   326  		want    string
   327  		wantErr assert.ErrorAssertionFunc
   328  	}{
   329  		{
   330  			"want to include mods, but node_modules does not exist",
   331  			fields{
   332  				NPMDependencies: []string{"mod1"},
   333  			},
   334  			args{
   335  				tempDir: tempDir,
   336  				rootDir: "no-mods",
   337  				matcher: sauceignore.NewMatcher([]sauceignore.Pattern{}),
   338  			},
   339  			"",
   340  			func(t assert.TestingT, err error, args ...interface{}) bool {
   341  				return assert.EqualError(t, err, "unable to access 'node_modules' folder, but you have npm dependencies defined in your configuration; ensure that the folder exists and is accessible", args)
   342  			},
   343  		},
   344  		{
   345  			"have and want mods, but mods are ignored",
   346  			fields{
   347  				NPMDependencies: []string{"mod1"},
   348  			},
   349  			args{
   350  				tempDir: tempDir,
   351  				rootDir: "has-mods",
   352  				matcher: sauceignore.NewMatcher([]sauceignore.Pattern{sauceignore.NewPattern("/has-mods/node_modules")}),
   353  			},
   354  			"",
   355  			func(t assert.TestingT, err error, args ...interface{}) bool {
   356  				return assert.EqualError(t, err, "'node_modules' is ignored by sauceignore, but you have npm dependencies defined in your project; please remove 'node_modules' from your sauceignore file", args)
   357  			},
   358  		},
   359  		{
   360  			"have mods, don't want them and they are ignored",
   361  			fields{
   362  				NPMDependencies: []string{}, // no mods selected, because we don't want any
   363  			},
   364  			args{
   365  				tempDir: tempDir,
   366  				rootDir: "has-mods",
   367  				matcher: sauceignore.NewMatcher([]sauceignore.Pattern{sauceignore.NewPattern("/has-mods/node_modules")}),
   368  			},
   369  			"",
   370  			assert.NoError,
   371  		},
   372  		{
   373  			"no mods wanted and no mods exist",
   374  			fields{
   375  				NPMDependencies: []string{},
   376  			},
   377  			args{
   378  				tempDir: tempDir,
   379  				rootDir: "no-mods",
   380  				matcher: sauceignore.NewMatcher([]sauceignore.Pattern{}),
   381  			},
   382  			"",
   383  			assert.NoError,
   384  		},
   385  		{
   386  			"has and wants mods (happy path)",
   387  			fields{
   388  				NPMDependencies: []string{"mod1"},
   389  			},
   390  			args{
   391  				tempDir: tempDir,
   392  				rootDir: "has-mods",
   393  				matcher: sauceignore.NewMatcher([]sauceignore.Pattern{}),
   394  			},
   395  			filepath.Join(tempDir, "node_modules.zip"),
   396  			assert.NoError,
   397  		},
   398  		{
   399  			"want mods, but node_modules folder is empty",
   400  			fields{
   401  				NPMDependencies: []string{"mod1"},
   402  			},
   403  			args{
   404  				tempDir: tempDir,
   405  				rootDir: "empty-mods",
   406  				matcher: sauceignore.NewMatcher([]sauceignore.Pattern{}),
   407  			},
   408  			"",
   409  			func(t assert.TestingT, err error, args ...interface{}) bool {
   410  				return assert.EqualError(t, err, "unable to find required dependencies; please check 'node_modules' folder and make sure the dependencies exist", args)
   411  			},
   412  		},
   413  	}
   414  	for _, tt := range tests {
   415  		t.Run(tt.name, func(t *testing.T) {
   416  			r := &CloudRunner{
   417  				NPMDependencies: tt.fields.NPMDependencies,
   418  			}
   419  			got, err := zip.ArchiveNodeModules(tt.args.tempDir, tt.args.rootDir, tt.args.matcher, r.NPMDependencies)
   420  			if !tt.wantErr(t, err, fmt.Sprintf("archiveNodeModules(%v, %v, %v)", tt.args.tempDir, tt.args.rootDir, tt.args.matcher)) {
   421  				return
   422  			}
   423  			assert.Equalf(t, tt.want, got, "archiveNodeModules(%v, %v, %v)", tt.args.tempDir, tt.args.rootDir, tt.args.matcher)
   424  		})
   425  	}
   426  }
   427  
   428  func Test_arrayContains(t *testing.T) {
   429  	type args struct {
   430  		list []string
   431  		want string
   432  	}
   433  	tests := []struct {
   434  		name string
   435  		args args
   436  		want bool
   437  	}{
   438  		{
   439  			name: "Empty set",
   440  			args: args{
   441  				list: []string{},
   442  				want: "value",
   443  			},
   444  			want: false,
   445  		},
   446  		{
   447  			name: "Complete set - false",
   448  			args: args{
   449  				list: []string{"val1", "val2", "val3"},
   450  				want: "value",
   451  			},
   452  			want: false,
   453  		},
   454  		{
   455  			name: "Found",
   456  			args: args{
   457  				list: []string{"val1", "val2", "val3"},
   458  				want: "val1",
   459  			},
   460  			want: true,
   461  		},
   462  	}
   463  	for _, tt := range tests {
   464  		t.Run(tt.name, func(t *testing.T) {
   465  			assert.Equalf(t, tt.want, arrayContains(tt.args.list, tt.args.want), "arrayContains(%v, %v)", tt.args.list, tt.args.want)
   466  		})
   467  	}
   468  }
   469  
   470  func TestCloudRunner_loadSauceTestReport(t *testing.T) {
   471  	type args struct {
   472  		jobID string
   473  		isRDC bool
   474  	}
   475  	type fields struct {
   476  		GetJobAssetFileNamesFn   func(ctx context.Context, jobID string) ([]string, error)
   477  		GetJobAssetFileContentFn func(ctx context.Context, jobID, fileName string) ([]byte, error)
   478  	}
   479  	tests := []struct {
   480  		name    string
   481  		args    args
   482  		fields  fields
   483  		want    saucereport.SauceReport
   484  		wantErr assert.ErrorAssertionFunc
   485  	}{
   486  		{
   487  			name: "Complete unmarshall",
   488  			args: args{
   489  				jobID: "test1",
   490  				isRDC: false,
   491  			},
   492  			fields: fields{
   493  				GetJobAssetFileNamesFn: func(ctx context.Context, jobID string) ([]string, error) {
   494  					return []string{saucereport.SauceReportFileName}, nil
   495  				},
   496  				GetJobAssetFileContentFn: func(ctx context.Context, jobID, fileName string) ([]byte, error) {
   497  					if fileName == saucereport.SauceReportFileName {
   498  						return []byte(`{"status":"failed","attachments":[],"suites":[{"name":"cypress/e2e/examples/actions.cy.js","status":"failed","metadata":{},"suites":[{"name":"Actions","status":"failed","metadata":{},"suites":[],"attachments":[],"tests":[{"name":".type() - type into a DOM element","status":"passed","startTime":"2022-12-22T10:10:11.083Z","duration":1802,"metadata":{},"output":null,"attachments":[],"code":{"lines":["() => {","    // https://on.cypress.io/type","    cy.get('.action-email').type('fake@email.com').should('have.value', 'fake@email.com');","  }"]},"videoTimestamp":26.083},{"name":".type() - type into a wrong DOM element","status":"failed","startTime":"2022-12-22T10:10:12.907Z","duration":5010,"metadata":{},"output":"AssertionError: Timed out retrying after 4000ms: expected '<input#email1.form-control.action-email>' to have value 'wrongy@email.com', but the value was 'fake@email.com'\n\n  11 |     // https://on.cypress.io/type\n  12 |     cy.get('.action-email')\n> 13 |         .type('fake@email.com').should('have.value', 'wrongy@email.com')\n     |                                 ^\n  14 |   })\n  15 | })\n  16 | ","attachments":[{"name":"screenshot","path":"Actions -- .type() - type into a wrong DOM element (failed).png","contentType":"image/png"}],"code":{"lines":["() => {","    // https://on.cypress.io/type","    cy.get('.action-email').type('fake@email.com').should('have.value', 'wrongy@email.com');","  }"]},"videoTimestamp":27.907}]}],"attachments":[],"tests":[]}],"metadata":{}}`), nil
   499  					}
   500  					return []byte{}, errors.New("not-found")
   501  				},
   502  			},
   503  			wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
   504  				return err == nil
   505  			},
   506  			want: saucereport.SauceReport{
   507  				Status:      saucereport.StatusFailed,
   508  				Attachments: []saucereport.Attachment{},
   509  				Suites: []saucereport.Suite{
   510  					{
   511  						Name:        "cypress/e2e/examples/actions.cy.js",
   512  						Status:      saucereport.StatusFailed,
   513  						Attachments: []saucereport.Attachment{},
   514  						Metadata:    saucereport.Metadata{},
   515  						Tests:       []saucereport.Test{},
   516  						Suites: []saucereport.Suite{
   517  							{
   518  								Name:        "Actions",
   519  								Status:      saucereport.StatusFailed,
   520  								Attachments: []saucereport.Attachment{},
   521  								Suites:      []saucereport.Suite{},
   522  								Metadata:    saucereport.Metadata{},
   523  								Tests: []saucereport.Test{
   524  									{
   525  										Name:      ".type() - type into a DOM element",
   526  										Status:    saucereport.StatusPassed,
   527  										StartTime: time.Date(2022, 12, 22, 10, 10, 11, 83000000, time.UTC),
   528  										Duration:  1802,
   529  										Metadata:  saucereport.Metadata{},
   530  										Code: saucereport.Code{
   531  											Lines: []string{
   532  												"() => {",
   533  												"    // https://on.cypress.io/type",
   534  												"    cy.get('.action-email').type('fake@email.com').should('have.value', 'fake@email.com');",
   535  												"  }",
   536  											},
   537  										},
   538  										VideoTimestamp: 26.083,
   539  										Attachments:    []saucereport.Attachment{},
   540  									},
   541  									{
   542  										Name:      ".type() - type into a wrong DOM element",
   543  										Status:    saucereport.StatusFailed,
   544  										StartTime: time.Date(2022, 12, 22, 10, 10, 12, 907000000, time.UTC),
   545  										Duration:  5010,
   546  										Output:    "AssertionError: Timed out retrying after 4000ms: expected '<input#email1.form-control.action-email>' to have value 'wrongy@email.com', but the value was 'fake@email.com'\n\n  11 |     // https://on.cypress.io/type\n  12 |     cy.get('.action-email')\n> 13 |         .type('fake@email.com').should('have.value', 'wrongy@email.com')\n     |                                 ^\n  14 |   })\n  15 | })\n  16 | ",
   547  										Attachments: []saucereport.Attachment{
   548  											{
   549  												Name:        "screenshot",
   550  												Path:        "Actions -- .type() - type into a wrong DOM element (failed).png",
   551  												ContentType: "image/png",
   552  											},
   553  										},
   554  										Metadata: saucereport.Metadata{},
   555  										Code: saucereport.Code{
   556  											Lines: []string{
   557  												"() => {",
   558  												"    // https://on.cypress.io/type",
   559  												"    cy.get('.action-email').type('fake@email.com').should('have.value', 'wrongy@email.com');",
   560  												"  }",
   561  											},
   562  										},
   563  										VideoTimestamp: 27.907,
   564  									},
   565  								},
   566  							},
   567  						},
   568  					},
   569  				},
   570  			},
   571  		},
   572  	}
   573  
   574  	for _, tt := range tests {
   575  		t.Run(tt.name, func(t *testing.T) {
   576  			r := CloudRunner{
   577  				JobService: JobService{
   578  					VDCReader: &mocks.FakeJobReader{
   579  						GetJobAssetFileNamesFn:   tt.fields.GetJobAssetFileNamesFn,
   580  						GetJobAssetFileContentFn: tt.fields.GetJobAssetFileContentFn,
   581  					},
   582  				},
   583  			}
   584  			got, err := r.loadSauceTestReport(tt.args.jobID, tt.args.isRDC)
   585  			if !tt.wantErr(t, err, fmt.Sprintf("loadSauceTestReport(%v, %v)", tt.args.jobID, tt.args.isRDC)) {
   586  				return
   587  			}
   588  			assert.Equalf(t, tt.want, got, "loadSauceTestReport(%v, %v)", tt.args.jobID, tt.args.isRDC)
   589  		})
   590  	}
   591  }
   592  
   593  func TestCloudRunner_loadJUnitReport(t *testing.T) {
   594  	type args struct {
   595  		jobID string
   596  		isRDC bool
   597  	}
   598  	type fields struct {
   599  		GetJobAssetFileNamesFn   func(ctx context.Context, jobID string) ([]string, error)
   600  		GetJobAssetFileContentFn func(ctx context.Context, jobID, fileName string) ([]byte, error)
   601  	}
   602  	tests := []struct {
   603  		name    string
   604  		fields  fields
   605  		args    args
   606  		want    junit.TestSuites
   607  		wantErr assert.ErrorAssertionFunc
   608  	}{
   609  		{
   610  			name: "Unmarshall XML",
   611  			fields: fields{
   612  				GetJobAssetFileNamesFn: func(ctx context.Context, jobID string) ([]string, error) {
   613  					return []string{junit.FileName}, nil
   614  				},
   615  				GetJobAssetFileContentFn: func(ctx context.Context, jobID, fileName string) ([]byte, error) {
   616  					if fileName == junit.FileName {
   617  						return []byte(`<?xml version="1.0" encoding="utf-8"?><testsuite package="com.saucelabs.mydemoapp.android" tests="7" time="52.056"><testcase classname="com.saucelabs.mydemoapp.android.view.activities.DashboardToCheckout" name="dashboardProductTest" status="success"/><testcase classname="com.saucelabs.mydemoapp.android.view.activities.LoginTest" name="succesfulLoginTest" status="success"/><testcase classname="com.saucelabs.mydemoapp.android.view.activities.LoginTest" name="noUsernameLoginTest" status="success"/><testcase classname="com.saucelabs.mydemoapp.android.view.activities.LoginTest" name="noPasswordLoginTest" status="success"/><testcase classname="com.saucelabs.mydemoapp.android.view.activities.LoginTest" name="noCredentialLoginTest" status="success"/><testcase classname="com.saucelabs.mydemoapp.android.view.activities.WebViewTest" name="webViewTest" status="success"/><testcase classname="com.saucelabs.mydemoapp.android.view.activities.WebViewTest" name="withoutUrlTest" status="success"/><system-out>INSTRUMENTATION_STATUS: class=com.saucelabs.mydemoapp.android.view.activities.DashboardToCheckout</system-out></testsuite>`), nil
   618  					}
   619  					return []byte{}, errors.New("not-found")
   620  				},
   621  			},
   622  			args: args{
   623  				jobID: "dummy-jobID",
   624  				isRDC: false,
   625  			},
   626  			want: junit.TestSuites{
   627  				TestSuites: []junit.TestSuite{
   628  					{
   629  						Package: "com.saucelabs.mydemoapp.android",
   630  						Tests:   7,
   631  						Time:    "52.056",
   632  						TestCases: []junit.TestCase{
   633  							{
   634  								ClassName: "com.saucelabs.mydemoapp.android.view.activities.DashboardToCheckout",
   635  								Name:      "dashboardProductTest",
   636  								Status:    "success",
   637  							},
   638  							{
   639  								ClassName: "com.saucelabs.mydemoapp.android.view.activities.LoginTest",
   640  								Name:      "succesfulLoginTest",
   641  								Status:    "success",
   642  							},
   643  							{
   644  								ClassName: "com.saucelabs.mydemoapp.android.view.activities.LoginTest",
   645  								Name:      "noUsernameLoginTest",
   646  								Status:    "success",
   647  							},
   648  							{
   649  								ClassName: "com.saucelabs.mydemoapp.android.view.activities.LoginTest",
   650  								Name:      "noPasswordLoginTest",
   651  								Status:    "success",
   652  							},
   653  							{
   654  								ClassName: "com.saucelabs.mydemoapp.android.view.activities.LoginTest",
   655  								Name:      "noCredentialLoginTest",
   656  								Status:    "success",
   657  							},
   658  							{
   659  								ClassName: "com.saucelabs.mydemoapp.android.view.activities.WebViewTest",
   660  								Name:      "webViewTest",
   661  								Status:    "success",
   662  							},
   663  							{
   664  								ClassName: "com.saucelabs.mydemoapp.android.view.activities.WebViewTest",
   665  								Name:      "withoutUrlTest",
   666  								Status:    "success",
   667  							},
   668  						},
   669  						SystemOut: "INSTRUMENTATION_STATUS: class=com.saucelabs.mydemoapp.android.view.activities.DashboardToCheckout",
   670  					},
   671  				},
   672  			},
   673  			wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
   674  				return err == nil
   675  			},
   676  		},
   677  	}
   678  	for _, tt := range tests {
   679  		t.Run(tt.name, func(t *testing.T) {
   680  			r := &CloudRunner{
   681  				JobService: JobService{
   682  					VDCReader: &mocks.FakeJobReader{
   683  						GetJobAssetFileNamesFn:   tt.fields.GetJobAssetFileNamesFn,
   684  						GetJobAssetFileContentFn: tt.fields.GetJobAssetFileContentFn,
   685  					},
   686  				},
   687  			}
   688  			got, err := r.loadJUnitReport(tt.args.jobID, tt.args.isRDC)
   689  			if !tt.wantErr(t, err, fmt.Sprintf("loadJUnitReport(%v, %v)", tt.args.jobID, tt.args.isRDC)) {
   690  				return
   691  			}
   692  			assert.Equalf(t, tt.want, got, "loadJUnitReport(%v, %v)", tt.args.jobID, tt.args.isRDC)
   693  		})
   694  	}
   695  }