github.com/saucelabs/saucectl@v0.175.1/internal/http/rdcservice.go (about)

     1  package http
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"net/http"
    11  	"os"
    12  	"path/filepath"
    13  	"strings"
    14  	"time"
    15  
    16  	"github.com/saucelabs/saucectl/internal/slice"
    17  
    18  	"github.com/hashicorp/go-retryablehttp"
    19  	"github.com/rs/zerolog/log"
    20  
    21  	"github.com/saucelabs/saucectl/internal/config"
    22  	"github.com/saucelabs/saucectl/internal/devices"
    23  	"github.com/saucelabs/saucectl/internal/espresso"
    24  	"github.com/saucelabs/saucectl/internal/fpath"
    25  	"github.com/saucelabs/saucectl/internal/job"
    26  	"github.com/saucelabs/saucectl/internal/xcuitest"
    27  )
    28  
    29  // RDCService http client.
    30  type RDCService struct {
    31  	Client         *retryablehttp.Client
    32  	URL            string
    33  	Username       string
    34  	AccessKey      string
    35  	ArtifactConfig config.ArtifactDownload
    36  }
    37  
    38  type rdcJob struct {
    39  	ID                string `json:"id"`
    40  	Name              string `json:"name"`
    41  	AutomationBackend string `json:"automation_backend,omitempty"`
    42  	FrameworkLogURL   string `json:"framework_log_url,omitempty"`
    43  	DeviceLogURL      string `json:"device_log_url,omitempty"`
    44  	TestCasesURL      string `json:"test_cases_url,omitempty"`
    45  	VideoURL          string `json:"video_url,omitempty"`
    46  	Screenshots       []struct {
    47  		ID string
    48  	} `json:"screenshots,omitempty"`
    49  	Status             string `json:"status,omitempty"`
    50  	Passed             bool   `json:"passed,omitempty"`
    51  	ConsolidatedStatus string `json:"consolidated_status,omitempty"`
    52  	Error              string `json:"error,omitempty"`
    53  	OS                 string `json:"os,omitempty"`
    54  	OSVersion          string `json:"os_version,omitempty"`
    55  	DeviceName         string `json:"device_name,omitempty"`
    56  }
    57  
    58  // RDCSessionRequest represents the RDC session request.
    59  type RDCSessionRequest struct {
    60  	TestFramework       string            `json:"test_framework,omitempty"`
    61  	AppID               string            `json:"app_id,omitempty"`
    62  	TestAppID           string            `json:"test_app_id,omitempty"`
    63  	OtherApps           []string          `json:"other_apps,omitempty"`
    64  	DeviceQuery         DeviceQuery       `json:"device_query,omitempty"`
    65  	TestOptions         map[string]string `json:"test_options,omitempty"`
    66  	TestsToRun          []string          `json:"tests_to_run,omitempty"`
    67  	TestsToSkip         []string          `json:"tests_to_skip,omitempty"`
    68  	TestName            string            `json:"test_name,omitempty"`
    69  	TunnelName          string            `json:"tunnel_name,omitempty"`
    70  	TunnelOwner         string            `json:"tunnel_owner,omitempty"`
    71  	UseTestOrchestrator bool              `json:"use_test_orchestrator,omitempty"`
    72  	Tags                []string          `json:"tags,omitempty"`
    73  	Build               string            `json:"build,omitempty"`
    74  	AppSettings         job.AppSettings   `json:"settings_overwrite,omitempty"`
    75  	RealDeviceKind      string            `json:"kind,omitempty"`
    76  }
    77  
    78  // DeviceQuery represents the device selection query for RDC.
    79  type DeviceQuery struct {
    80  	Type                         string `json:"type"`
    81  	DeviceDescriptorID           string `json:"device_descriptor_id,omitempty"`
    82  	PrivateDevicesOnly           bool   `json:"private_devices_only,omitempty"`
    83  	CarrierConnectivityRequested bool   `json:"carrier_connectivity_requested,omitempty"`
    84  	RequestedDeviceType          string `json:"requested_device_type,omitempty"`
    85  	DeviceName                   string `json:"device_name,omitempty"`
    86  	PlatformVersion              string `json:"platform_version,omitempty"`
    87  }
    88  
    89  // NewRDCService creates a new client.
    90  func NewRDCService(url, username, accessKey string, timeout time.Duration, artifactConfig config.ArtifactDownload) RDCService {
    91  	return RDCService{
    92  		Client:         NewRetryableClient(timeout),
    93  		URL:            url,
    94  		Username:       username,
    95  		AccessKey:      accessKey,
    96  		ArtifactConfig: artifactConfig,
    97  	}
    98  }
    99  
   100  // StartJob creates a new job in Sauce Labs.
   101  func (c *RDCService) StartJob(ctx context.Context, opts job.StartOptions) (jobID string, isRDC bool, err error) {
   102  	url := fmt.Sprintf("%s/v1/rdc/native-composer/tests", c.URL)
   103  
   104  	var frameworkName string
   105  	switch opts.Framework {
   106  	case "espresso":
   107  		frameworkName = "ANDROID_INSTRUMENTATION"
   108  	case "xcuitest":
   109  		frameworkName = "XCUITEST"
   110  	}
   111  
   112  	useTestOrchestrator := false
   113  	if v, ok := opts.TestOptions["useTestOrchestrator"]; ok {
   114  		useTestOrchestrator = fmt.Sprintf("%v", v) == "true"
   115  	}
   116  
   117  	jobReq := RDCSessionRequest{
   118  		TestName:            opts.Name,
   119  		AppID:               opts.App,
   120  		TestAppID:           opts.Suite,
   121  		OtherApps:           opts.OtherApps,
   122  		TestOptions:         c.formatEspressoArgs(opts.TestOptions),
   123  		TestsToRun:          opts.TestsToRun,
   124  		TestsToSkip:         opts.TestsToSkip,
   125  		DeviceQuery:         c.deviceQuery(opts),
   126  		TestFramework:       frameworkName,
   127  		TunnelName:          opts.Tunnel.ID,
   128  		TunnelOwner:         opts.Tunnel.Parent,
   129  		UseTestOrchestrator: useTestOrchestrator,
   130  		Tags:                opts.Tags,
   131  		Build:               opts.Build,
   132  		RealDeviceKind:      opts.RealDeviceKind,
   133  		AppSettings:         opts.AppSettings,
   134  	}
   135  
   136  	var b bytes.Buffer
   137  	err = json.NewEncoder(&b).Encode(jobReq)
   138  	if err != nil {
   139  		return
   140  	}
   141  
   142  	req, err := NewRequestWithContext(ctx, http.MethodPost, url, &b)
   143  	if err != nil {
   144  		return
   145  	}
   146  	req.Header.Set("Content-Type", "application/json")
   147  	req.SetBasicAuth(c.Username, c.AccessKey)
   148  
   149  	resp, err := c.Client.HTTPClient.Do(req)
   150  	if err != nil {
   151  		return
   152  	}
   153  	defer resp.Body.Close()
   154  	body, err := io.ReadAll(resp.Body)
   155  	if err != nil {
   156  		return
   157  	}
   158  
   159  	if resp.StatusCode >= 300 {
   160  		err = fmt.Errorf("job start failed; unexpected response code:'%d', msg:'%v'", resp.StatusCode, strings.TrimSpace(string(body)))
   161  		return "", true, err
   162  	}
   163  
   164  	var sessionStart struct {
   165  		TestReport struct {
   166  			ID string
   167  		} `json:"test_report"`
   168  	}
   169  	if err = json.Unmarshal(body, &sessionStart); err != nil {
   170  		return "", true, fmt.Errorf("job start status unknown: unable to parse server response: %w", err)
   171  	}
   172  
   173  	return sessionStart.TestReport.ID, true, nil
   174  }
   175  
   176  func (c *RDCService) StopJob(ctx context.Context, id string, realDevice bool) (job.Job, error) {
   177  	if !realDevice {
   178  		return job.Job{}, errors.New("the RDC client does not support virtual device jobs")
   179  	}
   180  
   181  	req, err := NewRequestWithContext(ctx, http.MethodPut,
   182  		fmt.Sprintf("%s/v1/rdc/jobs/%s/stop", c.URL, id), nil)
   183  	if err != nil {
   184  		return job.Job{}, err
   185  	}
   186  	req.SetBasicAuth(c.Username, c.AccessKey)
   187  
   188  	r, err := retryablehttp.FromRequest(req)
   189  	if err != nil {
   190  		return job.Job{}, err
   191  	}
   192  
   193  	resp, err := c.Client.Do(r)
   194  	if err != nil {
   195  		return job.Job{}, err
   196  	}
   197  	defer resp.Body.Close()
   198  
   199  	if resp.StatusCode >= http.StatusInternalServerError {
   200  		return job.Job{}, ErrServerError
   201  	}
   202  
   203  	if resp.StatusCode == http.StatusNotFound {
   204  		return job.Job{}, ErrJobNotFound
   205  	}
   206  
   207  	if resp.StatusCode != http.StatusOK {
   208  		body, _ := io.ReadAll(resp.Body)
   209  		err := fmt.Errorf("unable to stop job: %d - %s", resp.StatusCode, string(body))
   210  		return job.Job{}, err
   211  	}
   212  
   213  	// RDC does not return any job details in the response.
   214  	return job.Job{}, nil
   215  }
   216  
   217  // ReadJob returns the job details.
   218  func (c *RDCService) ReadJob(ctx context.Context, id string, realDevice bool) (job.Job, error) {
   219  	if !realDevice {
   220  		return job.Job{}, errors.New("the RDC client does not support virtual device jobs")
   221  	}
   222  
   223  	req, err := NewRequestWithContext(ctx, http.MethodGet,
   224  		fmt.Sprintf("%s/v1/rdc/jobs/%s", c.URL, id), nil)
   225  	if err != nil {
   226  		return job.Job{}, err
   227  	}
   228  	req.SetBasicAuth(c.Username, c.AccessKey)
   229  
   230  	r, err := retryablehttp.FromRequest(req)
   231  	if err != nil {
   232  		return job.Job{}, err
   233  	}
   234  
   235  	resp, err := c.Client.Do(r)
   236  	if err != nil {
   237  		return job.Job{}, err
   238  	}
   239  	defer resp.Body.Close()
   240  
   241  	if resp.StatusCode >= http.StatusInternalServerError {
   242  		return job.Job{}, ErrServerError
   243  	}
   244  
   245  	if resp.StatusCode == http.StatusNotFound {
   246  		return job.Job{}, ErrJobNotFound
   247  	}
   248  
   249  	if resp.StatusCode != 200 {
   250  		return job.Job{}, fmt.Errorf("unexpected statusCode: %v", resp.StatusCode)
   251  	}
   252  
   253  	return c.parseJob(resp.Body)
   254  }
   255  
   256  // PollJob polls job details at an interval, until timeout has been reached or until the job has ended, whether successfully or due to an error.
   257  func (c *RDCService) PollJob(ctx context.Context, id string, interval, timeout time.Duration, realDevice bool) (job.Job, error) {
   258  	if !realDevice {
   259  		return job.Job{}, errors.New("the RDC client does not support virtual device jobs")
   260  	}
   261  
   262  	ticker := time.NewTicker(interval)
   263  	defer ticker.Stop()
   264  
   265  	if timeout <= 0 {
   266  		timeout = 24 * time.Hour
   267  	}
   268  	deathclock := time.NewTimer(timeout)
   269  	defer deathclock.Stop()
   270  
   271  	for {
   272  		select {
   273  		case <-ticker.C:
   274  			j, err := c.ReadJob(ctx, id, realDevice)
   275  			if err != nil {
   276  				return job.Job{}, err
   277  			}
   278  
   279  			if job.Done(j.Status) {
   280  				j.IsRDC = true
   281  				return j, nil
   282  			}
   283  		case <-deathclock.C:
   284  			j, err := c.ReadJob(ctx, id, realDevice)
   285  			if err != nil {
   286  				return job.Job{}, err
   287  			}
   288  			j.TimedOut = true
   289  			return j, nil
   290  		}
   291  	}
   292  }
   293  
   294  // GetJobAssetFileNames returns all assets files available.
   295  func (c *RDCService) GetJobAssetFileNames(ctx context.Context, jobID string, realDevice bool) ([]string, error) {
   296  	if !realDevice {
   297  		return nil, errors.New("the RDC client does not support virtual device jobs")
   298  	}
   299  
   300  	req, err := NewRequestWithContext(ctx, http.MethodGet,
   301  		fmt.Sprintf("%s/v1/rdc/jobs/%s", c.URL, jobID), nil)
   302  	if err != nil {
   303  		return []string{}, err
   304  	}
   305  	req.SetBasicAuth(c.Username, c.AccessKey)
   306  
   307  	r, err := retryablehttp.FromRequest(req)
   308  	if err != nil {
   309  		return []string{}, err
   310  	}
   311  
   312  	resp, err := c.Client.Do(r)
   313  	if err != nil {
   314  		return []string{}, err
   315  	}
   316  	defer resp.Body.Close()
   317  
   318  	if resp.StatusCode != 200 {
   319  		return []string{}, fmt.Errorf("unexpected statusCode: %v", resp.StatusCode)
   320  	}
   321  
   322  	var jr rdcJob
   323  	if err := json.NewDecoder(resp.Body).Decode(&jr); err != nil {
   324  		return []string{}, err
   325  	}
   326  
   327  	var files []string
   328  
   329  	if strings.HasSuffix(jr.DeviceLogURL, "/deviceLogs") {
   330  		files = append(files, "device.log")
   331  	}
   332  	if strings.HasSuffix(jr.VideoURL, "/video.mp4") {
   333  		files = append(files, "video.mp4")
   334  	}
   335  	if len(jr.Screenshots) > 0 {
   336  		files = append(files, "screenshots.zip")
   337  	}
   338  
   339  	// xcuitest.log is available for espresso according to API, but will always be empty,
   340  	// => hiding it until API is fixed.
   341  	if jr.AutomationBackend == xcuitest.Kind && strings.HasSuffix(jr.FrameworkLogURL, "/xcuitestLogs") {
   342  		files = append(files, "xcuitest.log")
   343  	}
   344  	// junit.xml is available only for native frameworks.
   345  	if jr.AutomationBackend == xcuitest.Kind || jr.AutomationBackend == espresso.Kind {
   346  		files = append(files, "junit.xml")
   347  	}
   348  	return files, nil
   349  }
   350  
   351  // GetJobAssetFileContent returns the job asset file content.
   352  func (c *RDCService) GetJobAssetFileContent(ctx context.Context, jobID, fileName string, realDevice bool) ([]byte, error) {
   353  	if !realDevice {
   354  		return nil, errors.New("the RDC client does not support virtual device jobs")
   355  	}
   356  
   357  	// jobURIMappings contains the assets that don't get accessed by their filename.
   358  	// Those items also requires to send "Accept: text/plain" header to get raw content instead of json.
   359  	var jobURIMappings = map[string]string{
   360  		"device.log":   "deviceLogs",
   361  		"xcuitest.log": "xcuitestLogs",
   362  	}
   363  
   364  	acceptHeader := ""
   365  	URIFileName := fileName
   366  	if _, ok := jobURIMappings[fileName]; ok {
   367  		URIFileName = jobURIMappings[fileName]
   368  		acceptHeader = "text/plain"
   369  	}
   370  
   371  	req, err := NewRequestWithContext(ctx, http.MethodGet,
   372  		fmt.Sprintf("%s/v1/rdc/jobs/%s/%s", c.URL, jobID, URIFileName), nil)
   373  	if err != nil {
   374  		return nil, err
   375  	}
   376  
   377  	req.SetBasicAuth(c.Username, c.AccessKey)
   378  	if acceptHeader != "" {
   379  		req.Header.Set("Accept", acceptHeader)
   380  	}
   381  
   382  	rreq, err := retryablehttp.FromRequest(req)
   383  	if err != nil {
   384  		return nil, err
   385  	}
   386  	resp, err := c.Client.Do(rreq)
   387  	if err != nil {
   388  		return nil, err
   389  	}
   390  	defer resp.Body.Close()
   391  
   392  	if resp.StatusCode >= http.StatusInternalServerError {
   393  		return nil, ErrServerError
   394  	}
   395  
   396  	if resp.StatusCode == http.StatusNotFound {
   397  		return nil, ErrAssetNotFound
   398  	}
   399  
   400  	if resp.StatusCode != http.StatusOK {
   401  		body, _ := io.ReadAll(resp.Body)
   402  		err := fmt.Errorf("job status request failed; unexpected response code:'%d', msg:'%v'", resp.StatusCode, string(body))
   403  		return nil, err
   404  	}
   405  
   406  	return io.ReadAll(resp.Body)
   407  }
   408  
   409  // DownloadArtifact downloads artifacts and returns a list of downloaded files.
   410  func (c *RDCService) DownloadArtifact(jobID, suiteName string, realDevice bool) []string {
   411  	targetDir, err := config.GetSuiteArtifactFolder(suiteName, c.ArtifactConfig)
   412  	if err != nil {
   413  		log.Error().Msgf("Unable to create artifacts folder (%v)", err)
   414  		return []string{}
   415  	}
   416  
   417  	files, err := c.GetJobAssetFileNames(context.Background(), jobID, realDevice)
   418  	if err != nil {
   419  		log.Error().Msgf("Unable to fetch artifacts list (%v)", err)
   420  		return []string{}
   421  	}
   422  
   423  	filepaths := fpath.MatchFiles(files, c.ArtifactConfig.Match)
   424  	var artifacts []string
   425  	for _, f := range filepaths {
   426  		targetFile, err := c.downloadArtifact(targetDir, jobID, f, realDevice)
   427  		if err != nil {
   428  			log.Err(err).Msg("Unable to download artifacts")
   429  			return artifacts
   430  		}
   431  		artifacts = append(artifacts, targetFile)
   432  	}
   433  
   434  	return artifacts
   435  }
   436  
   437  func (c *RDCService) downloadArtifact(targetDir, jobID, fileName string, realDevice bool) (string, error) {
   438  	content, err := c.GetJobAssetFileContent(context.Background(), jobID, fileName, realDevice)
   439  	if err != nil {
   440  		return "", err
   441  	}
   442  	targetFile := filepath.Join(targetDir, fileName)
   443  	return targetFile, os.WriteFile(targetFile, content, 0644)
   444  }
   445  
   446  // GetDevices returns the list of available devices using a specific operating system.
   447  func (c *RDCService) GetDevices(ctx context.Context, OS string) ([]devices.Device, error) {
   448  	req, err := NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/v1/rdc/devices/filtered", c.URL), nil)
   449  	if err != nil {
   450  		return nil, err
   451  	}
   452  
   453  	q := req.URL.Query()
   454  	q.Add("os", OS)
   455  	req.URL.RawQuery = q.Encode()
   456  	req.SetBasicAuth(c.Username, c.AccessKey)
   457  
   458  	r, err := retryablehttp.FromRequest(req)
   459  	if err != nil {
   460  		return nil, err
   461  	}
   462  
   463  	res, err := c.Client.Do(r)
   464  	if err != nil {
   465  		return []devices.Device{}, err
   466  	}
   467  
   468  	var resp struct {
   469  		Entities []struct {
   470  			Name string
   471  			OS   string
   472  		}
   473  	}
   474  	if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
   475  		return []devices.Device{}, err
   476  	}
   477  
   478  	var dev []devices.Device
   479  	for _, d := range resp.Entities {
   480  		dev = append(dev, devices.Device{
   481  			Name: d.Name,
   482  			OS:   d.OS,
   483  		})
   484  	}
   485  	return dev, nil
   486  }
   487  
   488  // formatEspressoArgs adapts option shape to match RDC expectations
   489  func (c *RDCService) formatEspressoArgs(options map[string]interface{}) map[string]string {
   490  	mappedOptions := map[string]string{}
   491  	for k, v := range options {
   492  		if v == nil {
   493  			continue
   494  		}
   495  		// We let the user set 'useTestOrchestrator' inside TestOptions, but RDC has a dedicated setting for it.
   496  		if k == "useTestOrchestrator" {
   497  			continue
   498  		}
   499  
   500  		value := fmt.Sprintf("%v", v)
   501  
   502  		// class/notClass need special treatment, because we accept these as slices, but the backend wants
   503  		// a comma separated string.
   504  		if k == "class" || k == "notClass" {
   505  			value = slice.Join(v, ",")
   506  		}
   507  
   508  		if value == "" {
   509  			continue
   510  		}
   511  		mappedOptions[k] = value
   512  	}
   513  	return mappedOptions
   514  }
   515  
   516  // deviceQuery creates a DeviceQuery from opts.
   517  func (c *RDCService) deviceQuery(opts job.StartOptions) DeviceQuery {
   518  	if opts.DeviceID != "" {
   519  		return DeviceQuery{
   520  			Type:               "HardcodedDeviceQuery",
   521  			DeviceDescriptorID: opts.DeviceID,
   522  		}
   523  	}
   524  	return DeviceQuery{
   525  		Type:                         "DynamicDeviceQuery",
   526  		CarrierConnectivityRequested: opts.DeviceHasCarrier,
   527  		DeviceName:                   opts.DeviceName,
   528  		PlatformVersion:              opts.PlatformVersion,
   529  		PrivateDevicesOnly:           opts.DevicePrivateOnly,
   530  		RequestedDeviceType:          opts.DeviceType,
   531  	}
   532  }
   533  
   534  // parseJob parses the body into rdcJob and converts it to job.Job.
   535  func (c *RDCService) parseJob(body io.ReadCloser) (job.Job, error) {
   536  	var j rdcJob
   537  	err := json.NewDecoder(body).Decode(&j)
   538  	return job.Job{
   539  		ID:         j.ID,
   540  		Name:       j.Name,
   541  		Error:      j.Error,
   542  		Status:     j.Status,
   543  		Passed:     j.Status == job.StatePassed,
   544  		DeviceName: j.DeviceName,
   545  		Framework:  j.AutomationBackend,
   546  		OS:         j.OS,
   547  		OSVersion:  j.OSVersion,
   548  		IsRDC:      true,
   549  	}, err
   550  }