github.com/saucelabs/saucectl@v0.175.1/internal/xcuitest/config_test.go (about)

     1  package xcuitest
     2  
     3  import (
     4  	"errors"
     5  	"os"
     6  	"path/filepath"
     7  	"reflect"
     8  	"testing"
     9  
    10  	"github.com/saucelabs/saucectl/internal/config"
    11  	"github.com/saucelabs/saucectl/internal/insights"
    12  
    13  	"github.com/google/go-cmp/cmp"
    14  	"github.com/stretchr/testify/assert"
    15  	"gotest.tools/v3/fs"
    16  )
    17  
    18  func TestTestOptions_ToMap(t *testing.T) {
    19  	opts := TestOptions{
    20  		Class:                             []string{},
    21  		NotClass:                          []string{},
    22  		TestLanguage:                      "",
    23  		TestRegion:                        "",
    24  		TestTimeoutsEnabled:               "",
    25  		MaximumTestExecutionTimeAllowance: 20,
    26  		DefaultTestExecutionTimeAllowance: 0,
    27  		StatusBarOverrideTime:             "",
    28  	}
    29  	wantLength := 8
    30  
    31  	m := opts.ToMap()
    32  
    33  	if len(m) != wantLength {
    34  		t.Errorf("Length of converted TestOptions should match original, got (%v) want (%v)", len(m), wantLength)
    35  	}
    36  
    37  	v := reflect.ValueOf(m["maximumTestExecutionTimeAllowance"])
    38  	vtype := v.Type()
    39  	if vtype.Kind() != reflect.String {
    40  		t.Errorf("ints should be converted to strings when mapping, got (%v) want (%v)", vtype, reflect.String)
    41  	}
    42  
    43  	if v := m["defaultTestExecutionTimeAllowance"]; v != "" {
    44  		t.Errorf("0 values should be cast to empty strings, got (%v)", v)
    45  	}
    46  }
    47  
    48  func TestValidate(t *testing.T) {
    49  	dir := fs.NewDir(t, "xcuitest-config",
    50  		fs.WithFile("test.ipa", "", fs.WithMode(0655)),
    51  		fs.WithFile("testApp.ipa", "", fs.WithMode(0655)),
    52  		fs.WithDir("test.app", fs.WithMode(0755)),
    53  		fs.WithDir("testApp.app", fs.WithMode(0755)))
    54  	defer dir.Remove()
    55  	appF := filepath.Join(dir.Path(), "test.ipa")
    56  	testAppF := filepath.Join(dir.Path(), "testApp.ipa")
    57  	appD := filepath.Join(dir.Path(), "test.app")
    58  	testAppD := filepath.Join(dir.Path(), "testApp.app")
    59  
    60  	testCases := []struct {
    61  		name        string
    62  		p           *Project
    63  		expectedErr error
    64  	}{
    65  		{
    66  			name: "validating throws error on empty app",
    67  			p: &Project{
    68  				Sauce: config.SauceConfig{Region: "us-west-1"},
    69  				Suites: []Suite{
    70  					{
    71  						Name: "suite with missing app",
    72  						Devices: []config.Device{
    73  							{Name: "iPhone.*"},
    74  						},
    75  					},
    76  				},
    77  			},
    78  			expectedErr: errors.New("missing path to app .ipa"),
    79  		},
    80  		{
    81  			name: "validating passing with .ipa",
    82  			p: &Project{
    83  				Sauce: config.SauceConfig{Region: "us-west-1"},
    84  				Suites: []Suite{
    85  					{
    86  						Name:    "iphone",
    87  						App:     appF,
    88  						TestApp: testAppF,
    89  						Devices: []config.Device{
    90  							{Name: "iPhone.*"},
    91  						},
    92  					},
    93  				},
    94  			},
    95  			expectedErr: nil,
    96  		},
    97  		{
    98  			name: "validating passing with .app",
    99  			p: &Project{
   100  				Sauce: config.SauceConfig{Region: "us-west-1"},
   101  				Suites: []Suite{
   102  					{
   103  						Name:    "iphone",
   104  						App:     appD,
   105  						TestApp: testAppD,
   106  						Devices: []config.Device{
   107  							{Name: "iPhone.*"},
   108  						},
   109  					},
   110  				},
   111  			},
   112  			expectedErr: nil,
   113  		},
   114  		{
   115  			name: "validating error with app other than .ipa / .app",
   116  			p: &Project{
   117  				Sauce: config.SauceConfig{Region: "us-west-1"},
   118  				Suites: []Suite{
   119  					{
   120  						Name:    "suite with invalid apps",
   121  						App:     "/path/to/app.zip",
   122  						TestApp: testAppD,
   123  						Devices: []config.Device{
   124  							{Name: "iPhone.*"},
   125  						},
   126  					},
   127  				},
   128  			},
   129  			expectedErr: errors.New("invalid application file: /path/to/app.zip, make sure extension is one of the following: .app, .ipa"),
   130  		},
   131  		{
   132  			name: "validating error with test app other than .ipa / .app",
   133  			p: &Project{
   134  				Sauce: config.SauceConfig{Region: "us-west-1"},
   135  				Suites: []Suite{
   136  					{
   137  						App:     appF,
   138  						TestApp: "/path/to/app.zip",
   139  						Devices: []config.Device{
   140  							{Name: "iPhone.*"},
   141  						},
   142  					},
   143  				},
   144  			},
   145  			expectedErr: errors.New("invalid test application file: /path/to/app.zip, make sure extension is one of the following: .app, .ipa"),
   146  		},
   147  		{
   148  			name: "validating throws error on empty testApp",
   149  			p: &Project{
   150  				Sauce: config.SauceConfig{Region: "us-west-1"},
   151  				Suites: []Suite{
   152  					{
   153  						Name:    "missing test app",
   154  						App:     appF,
   155  						TestApp: "",
   156  						Devices: []config.Device{
   157  							{Name: "iPhone.*"},
   158  						},
   159  					},
   160  				},
   161  			},
   162  			expectedErr: errors.New("missing path to test app .ipa"),
   163  		},
   164  		{
   165  			name: "validating throws error on not test app .ipa",
   166  			p: &Project{
   167  				Sauce: config.SauceConfig{Region: "us-west-1"},
   168  				Suites: []Suite{
   169  					{
   170  						App:     appF,
   171  						TestApp: "/path/to/bundle/tests",
   172  						Devices: []config.Device{
   173  							{Name: "iPhone.*"},
   174  						},
   175  					},
   176  				},
   177  			},
   178  			expectedErr: errors.New("invalid test application file: /path/to/bundle/tests, make sure extension is one of the following: .app, .ipa"),
   179  		},
   180  		{
   181  			name: "validating throws error on missing suites",
   182  			p: &Project{
   183  				Sauce: config.SauceConfig{Region: "us-west-1"},
   184  				Xcuitest: Xcuitest{
   185  					App:     appF,
   186  					TestApp: testAppF,
   187  				},
   188  			},
   189  			expectedErr: errors.New("no suites defined"),
   190  		},
   191  		{
   192  			name: "validating throws error on missing devices",
   193  			p: &Project{
   194  				Sauce: config.SauceConfig{Region: "us-west-1"},
   195  				Suites: []Suite{
   196  					{
   197  						Name:    "no devices",
   198  						App:     appF,
   199  						TestApp: testAppF,
   200  						Devices: []config.Device{},
   201  					},
   202  				},
   203  			},
   204  			expectedErr: errors.New("missing devices configuration for suite: no devices"),
   205  		},
   206  		{
   207  			name: "validating throws error on missing device name",
   208  			p: &Project{
   209  				Sauce: config.SauceConfig{Region: "us-west-1"},
   210  				Suites: []Suite{
   211  					{
   212  						Name:    "no device name",
   213  						App:     appF,
   214  						TestApp: testAppF,
   215  						Devices: []config.Device{
   216  							{
   217  								Name: "",
   218  							},
   219  						},
   220  					},
   221  				},
   222  			},
   223  			expectedErr: errors.New("missing device name or ID for suite: no device name. Devices index: 0"),
   224  		},
   225  		{
   226  			name: "validating throws error on unsupported device type",
   227  			p: &Project{
   228  				Sauce: config.SauceConfig{Region: "us-west-1"},
   229  				Suites: []Suite{
   230  					{
   231  						Name:    "unsupported device type",
   232  						App:     appF,
   233  						TestApp: testAppF,
   234  						Devices: []config.Device{
   235  							{
   236  								Name:         "iPhone 11",
   237  								PlatformName: "iOS",
   238  								Options: config.DeviceOptions{
   239  									DeviceType: "some",
   240  								},
   241  							},
   242  						},
   243  					},
   244  				},
   245  			},
   246  			expectedErr: errors.New("deviceType: some is unsupported for suite: unsupported device type. Devices index: 0. Supported device types: ANY,PHONE,TABLET"),
   247  		},
   248  		{
   249  			name: "throws error if devices and simulators are defined",
   250  			p: &Project{
   251  				Sauce: config.SauceConfig{Region: "us-west-1"},
   252  				Suites: []Suite{
   253  					{
   254  						Name:    "",
   255  						App:     appF,
   256  						TestApp: testAppF,
   257  						Simulators: []config.Simulator{
   258  							{
   259  								Name:             "iPhone 12 Simulator",
   260  								PlatformName:     "iOS",
   261  								PlatformVersions: []string{"16.2"},
   262  							},
   263  						},
   264  						Devices: []config.Device{
   265  							{
   266  								Name:         "iPhone 11",
   267  								PlatformName: "iOS",
   268  								Options: config.DeviceOptions{
   269  									DeviceType: "some",
   270  								},
   271  							},
   272  						},
   273  					},
   274  				},
   275  			},
   276  			expectedErr: errors.New("suite cannot have both simulators and devices"),
   277  		},
   278  	}
   279  	for _, tc := range testCases {
   280  		t.Run(tc.name, func(t *testing.T) {
   281  			err := Validate(*tc.p)
   282  			if tc.expectedErr == nil && err != nil {
   283  				t.Errorf("want: %v, got: %v", tc.expectedErr, err)
   284  			}
   285  			if tc.expectedErr != nil && tc.expectedErr.Error() != err.Error() {
   286  				t.Errorf("want: %v, got: %v", tc.expectedErr, err)
   287  			}
   288  		})
   289  	}
   290  }
   291  
   292  func TestFromFile(t *testing.T) {
   293  	dir := fs.NewDir(t, "xcuitest-cfg",
   294  		fs.WithFile("config.yml", `apiVersion: v1alpha
   295  kind: xcuitest
   296  xcuitest:
   297    app: "./tests/apps/xcuitest/SauceLabs.Mobile.Sample.XCUITest.App.ipa"
   298    testApp: "./tests/apps/xcuitest/SwagLabsMobileAppUITests-Runner.ipa"
   299  suites:
   300    - name: "saucy barista"
   301      devices:
   302        - name: "iPhone XR"
   303          platformVersion: "14.3"
   304      testOptions:
   305        class: ["SwagLabsMobileAppUITests.LoginTests/testSuccessfulLogin", "SwagLabsMobileAppUITests.LoginTests"]
   306  `, fs.WithMode(0655)))
   307  	defer dir.Remove()
   308  
   309  	cfg, err := FromFile(filepath.Join(dir.Path(), "config.yml"))
   310  	if err != nil {
   311  		t.Errorf("expected error: %v, got: %v", nil, err)
   312  	}
   313  	expected := Project{
   314  		Xcuitest: Xcuitest{
   315  			App:     "./tests/apps/xcuitest/SauceLabs.Mobile.Sample.XCUITest.App.ipa",
   316  			TestApp: "./tests/apps/xcuitest/SwagLabsMobileAppUITests-Runner.ipa",
   317  		},
   318  		Suites: []Suite{
   319  			{
   320  				Name: "saucy barista",
   321  				Devices: []config.Device{
   322  					{
   323  						Name:            "iPhone XR",
   324  						PlatformVersion: "14.3",
   325  					},
   326  				},
   327  				TestOptions: TestOptions{
   328  					Class: []string{
   329  						"SwagLabsMobileAppUITests.LoginTests/testSuccessfulLogin",
   330  						"SwagLabsMobileAppUITests.LoginTests",
   331  					},
   332  				},
   333  			},
   334  		},
   335  	}
   336  	if !reflect.DeepEqual(cfg.Xcuitest, expected.Xcuitest) {
   337  		t.Errorf("expected: %v, got: %v", expected, cfg)
   338  	}
   339  	if !reflect.DeepEqual(cfg.Suites, expected.Suites) {
   340  		t.Errorf("expected: %v, got: %v", expected, cfg)
   341  	}
   342  }
   343  
   344  func TestSetDefaults_Platform(t *testing.T) {
   345  	type args struct {
   346  		Device config.Device
   347  	}
   348  	tests := []struct {
   349  		name string
   350  		args args
   351  		want string
   352  	}{
   353  		{
   354  			name: "no platform specified",
   355  			args: args{Device: config.Device{}},
   356  			want: "iOS",
   357  		},
   358  		{
   359  			name: "wrong platform specified",
   360  			args: args{Device: config.Device{PlatformName: "myOS"}},
   361  			want: "iOS",
   362  		},
   363  	}
   364  
   365  	for _, tt := range tests {
   366  		t.Run(tt.name, func(t *testing.T) {
   367  			p := Project{Suites: []Suite{{
   368  				Devices: []config.Device{tt.args.Device},
   369  			}}}
   370  
   371  			SetDefaults(&p)
   372  
   373  			got := p.Suites[0].Devices[0].PlatformName
   374  			if got != tt.want {
   375  				t.Errorf("SetDefaults() got: %v, want: %v", got, tt.want)
   376  			}
   377  		})
   378  	}
   379  }
   380  
   381  func TestSetDefaults_DeviceType(t *testing.T) {
   382  	type args struct {
   383  		Device config.Device
   384  	}
   385  	tests := []struct {
   386  		name string
   387  		args args
   388  		want string
   389  	}{
   390  		{
   391  			name: "device type is always uppercase",
   392  			args: args{Device: config.Device{Options: config.DeviceOptions{DeviceType: "phone"}}},
   393  			want: "PHONE",
   394  		},
   395  	}
   396  
   397  	for _, tt := range tests {
   398  		t.Run(tt.name, func(t *testing.T) {
   399  			p := Project{Suites: []Suite{{
   400  				Devices: []config.Device{tt.args.Device},
   401  			}}}
   402  
   403  			SetDefaults(&p)
   404  
   405  			got := p.Suites[0].Devices[0].Options.DeviceType
   406  			if got != tt.want {
   407  				t.Errorf("SetDefaults() got: %v, want: %v", got, tt.want)
   408  			}
   409  		})
   410  	}
   411  }
   412  
   413  func TestSetDefaults_TestApp(t *testing.T) {
   414  	testCase := []struct {
   415  		name      string
   416  		project   Project
   417  		expResult string
   418  	}{
   419  		{
   420  			name: "Set TestApp on suite level",
   421  			project: Project{
   422  				Xcuitest: Xcuitest{
   423  					TestApp: "test-app",
   424  				},
   425  				Suites: []Suite{
   426  					{
   427  						TestApp: "suite-test-app",
   428  					},
   429  				},
   430  			},
   431  			expResult: "suite-test-app",
   432  		},
   433  		{
   434  			name: "Set empty TestApp on suite level",
   435  			project: Project{
   436  				Xcuitest: Xcuitest{
   437  					TestApp: "test-app",
   438  				},
   439  				Suites: []Suite{
   440  					{},
   441  				},
   442  			},
   443  			expResult: "test-app",
   444  		},
   445  	}
   446  	for _, tc := range testCase {
   447  		t.Run(tc.name, func(t *testing.T) {
   448  			SetDefaults(&tc.project)
   449  			assert.Equal(t, tc.expResult, tc.project.Suites[0].TestApp)
   450  		})
   451  	}
   452  }
   453  
   454  func TestXCUITest_SortByHistory(t *testing.T) {
   455  	testCases := []struct {
   456  		name    string
   457  		suites  []Suite
   458  		history insights.JobHistory
   459  		expRes  []Suite
   460  	}{
   461  		{
   462  			name: "sort suites by job history",
   463  			suites: []Suite{
   464  				Suite{Name: "suite 1"},
   465  				Suite{Name: "suite 2"},
   466  				Suite{Name: "suite 3"},
   467  			},
   468  			history: insights.JobHistory{
   469  				TestCases: []insights.TestCase{
   470  					insights.TestCase{Name: "suite 2"},
   471  					insights.TestCase{Name: "suite 1"},
   472  					insights.TestCase{Name: "suite 3"},
   473  				},
   474  			},
   475  			expRes: []Suite{
   476  				Suite{Name: "suite 2"},
   477  				Suite{Name: "suite 1"},
   478  				Suite{Name: "suite 3"},
   479  			},
   480  		},
   481  		{
   482  			name: "suites is the subset of job history",
   483  			suites: []Suite{
   484  				Suite{Name: "suite 1"},
   485  				Suite{Name: "suite 2"},
   486  			},
   487  			history: insights.JobHistory{
   488  				TestCases: []insights.TestCase{
   489  					insights.TestCase{Name: "suite 2"},
   490  					insights.TestCase{Name: "suite 1"},
   491  					insights.TestCase{Name: "suite 3"},
   492  				},
   493  			},
   494  			expRes: []Suite{
   495  				Suite{Name: "suite 2"},
   496  				Suite{Name: "suite 1"},
   497  			},
   498  		},
   499  		{
   500  			name: "job history is the subset of suites",
   501  			suites: []Suite{
   502  				Suite{Name: "suite 1"},
   503  				Suite{Name: "suite 2"},
   504  				Suite{Name: "suite 3"},
   505  				Suite{Name: "suite 4"},
   506  				Suite{Name: "suite 5"},
   507  			},
   508  			history: insights.JobHistory{
   509  				TestCases: []insights.TestCase{
   510  					insights.TestCase{Name: "suite 2"},
   511  					insights.TestCase{Name: "suite 1"},
   512  					insights.TestCase{Name: "suite 3"},
   513  				},
   514  			},
   515  			expRes: []Suite{
   516  				Suite{Name: "suite 2"},
   517  				Suite{Name: "suite 1"},
   518  				Suite{Name: "suite 3"},
   519  				Suite{Name: "suite 4"},
   520  				Suite{Name: "suite 5"},
   521  			},
   522  		},
   523  	}
   524  
   525  	for _, tc := range testCases {
   526  		t.Run(tc.name, func(t *testing.T) {
   527  			result := SortByHistory(tc.suites, tc.history)
   528  			for i := 0; i < len(result); i++ {
   529  				assert.Equal(t, tc.expRes[i].Name, result[i].Name)
   530  			}
   531  		})
   532  	}
   533  }
   534  
   535  func TestXCUITest_ShardSuites(t *testing.T) {
   536  	testCases := []struct {
   537  		name          string
   538  		project       Project
   539  		content       string
   540  		configEnabled bool
   541  		expSuites     []Suite
   542  		expErr        bool
   543  	}{
   544  		{
   545  			name: "should keep original test options when sharding is disabled",
   546  			project: Project{
   547  				Suites: []Suite{
   548  					{
   549  						Name: "no shard",
   550  						TestOptions: TestOptions{
   551  							Class: []string{"no update"},
   552  						},
   553  					},
   554  				},
   555  			},
   556  			expSuites: []Suite{
   557  				{
   558  					Name: "no shard",
   559  					TestOptions: TestOptions{
   560  						Class: []string{"no update"},
   561  					},
   562  				},
   563  			},
   564  		},
   565  		{
   566  			name: "should shard tests by ccy when sharding is enabled",
   567  			project: Project{
   568  				Sauce: config.SauceConfig{
   569  					Concurrency: 2,
   570  				},
   571  				Suites: []Suite{
   572  					{
   573  						Name:  "sharding test",
   574  						Shard: "concurrency",
   575  					},
   576  				},
   577  			},
   578  			content:       "test1\ntest2\n",
   579  			configEnabled: true,
   580  			expSuites: []Suite{
   581  				{
   582  					Name: "sharding test - 1/2",
   583  					TestOptions: TestOptions{
   584  						Class: []string{"test1"},
   585  					},
   586  				},
   587  				{
   588  					Name: "sharding test - 2/2",
   589  					TestOptions: TestOptions{
   590  						Class: []string{"test2"},
   591  					},
   592  				},
   593  			},
   594  		},
   595  		{
   596  			name: "should ignore empty lines and spaces in testListFile when sharding is enabled",
   597  			project: Project{
   598  				Sauce: config.SauceConfig{
   599  					Concurrency: 2,
   600  				},
   601  				Suites: []Suite{
   602  					{
   603  						Name:  "sharding test",
   604  						Shard: "concurrency",
   605  					},
   606  				},
   607  			},
   608  			content:       "   test1\t\n\ntest2\t\n\n",
   609  			configEnabled: true,
   610  			expSuites: []Suite{
   611  				{
   612  					Name: "sharding test - 1/2",
   613  					TestOptions: TestOptions{
   614  						Class: []string{"test1"},
   615  					},
   616  				},
   617  				{
   618  					Name: "sharding test - 2/2",
   619  					TestOptions: TestOptions{
   620  						Class: []string{"test2"},
   621  					},
   622  				},
   623  			},
   624  		},
   625  		{
   626  			name: "should return error when sharding w/o a testListFile",
   627  			project: Project{
   628  				Sauce: config.SauceConfig{
   629  					Concurrency: 2,
   630  				},
   631  				Suites: []Suite{
   632  					{
   633  						Name:  "sharding test",
   634  						Shard: "concurrency",
   635  						TestOptions: TestOptions{
   636  							Class: []string{"test1"},
   637  						},
   638  					},
   639  				},
   640  			},
   641  			configEnabled: false,
   642  			expSuites: []Suite{
   643  				{
   644  					Name: "sharding test",
   645  					TestOptions: TestOptions{
   646  						Class: []string{"test1"},
   647  					},
   648  				},
   649  			},
   650  			expErr: true,
   651  		},
   652  		{
   653  			name: "should return error when sharding w/ an empty testListFile",
   654  			project: Project{
   655  				Sauce: config.SauceConfig{
   656  					Concurrency: 2,
   657  				},
   658  				Suites: []Suite{
   659  					{
   660  						Name:  "sharding test",
   661  						Shard: "concurrency",
   662  						TestOptions: TestOptions{
   663  							Class: []string{"test1"},
   664  						},
   665  					},
   666  				},
   667  			},
   668  			configEnabled: true,
   669  			content:       "",
   670  			expSuites: []Suite{
   671  				{
   672  					Name: "sharding test",
   673  					TestOptions: TestOptions{
   674  						Class: []string{"test1"},
   675  					},
   676  				},
   677  			},
   678  			expErr: true,
   679  		},
   680  	}
   681  
   682  	for _, tc := range testCases {
   683  		t.Run(tc.name, func(t *testing.T) {
   684  			var testListFile string
   685  			if tc.configEnabled {
   686  				testListFile = createTestListFile(t, tc.content)
   687  				tc.project.Suites[0].TestListFile = testListFile
   688  			}
   689  			err := ShardSuites(&tc.project)
   690  			if err != nil {
   691  				assert.True(t, tc.expErr)
   692  			}
   693  			for i, s := range tc.project.Suites {
   694  				assert.True(t, cmp.Equal(s.TestOptions, tc.expSuites[i].TestOptions))
   695  				assert.True(t, cmp.Equal(s.Name, tc.expSuites[i].Name))
   696  			}
   697  		})
   698  	}
   699  }
   700  
   701  func createTestListFile(t *testing.T, content string) string {
   702  	t.Helper()
   703  	tmpDir := t.TempDir()
   704  	file := filepath.Join(tmpDir, "tests.txt")
   705  	if err := os.WriteFile(file, []byte(content), 0644); err != nil {
   706  		t.Fatalf("Setup failed: could not write tests.txt: %v", err)
   707  		return ""
   708  	}
   709  	return file
   710  }