github.com/oam-dev/kubevela@v1.9.11/pkg/appfile/parser_test.go (about)

     1  /*
     2  Copyright 2021 The KubeVela 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 appfile
    18  
    19  import (
    20  	"context"
    21  	"fmt"
    22  	"reflect"
    23  	"strings"
    24  	"testing"
    25  
    26  	"github.com/crossplane/crossplane-runtime/pkg/test"
    27  	. "github.com/onsi/ginkgo/v2"
    28  	. "github.com/onsi/gomega"
    29  	"github.com/stretchr/testify/assert"
    30  	errors2 "k8s.io/apimachinery/pkg/api/errors"
    31  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    32  	"k8s.io/apimachinery/pkg/runtime"
    33  	"sigs.k8s.io/controller-runtime/pkg/client"
    34  	"sigs.k8s.io/controller-runtime/pkg/client/fake"
    35  	"sigs.k8s.io/yaml"
    36  
    37  	workflowv1alpha1 "github.com/kubevela/workflow/api/v1alpha1"
    38  
    39  	"github.com/oam-dev/kubevela/apis/core.oam.dev/common"
    40  	"github.com/oam-dev/kubevela/apis/types"
    41  
    42  	"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
    43  	"github.com/oam-dev/kubevela/pkg/oam/util"
    44  	common2 "github.com/oam-dev/kubevela/pkg/utils/common"
    45  )
    46  
    47  var expectedExceptApp = &Appfile{
    48  	Name: "application-sample",
    49  	ParsedComponents: []*Component{
    50  		{
    51  			Name: "myweb",
    52  			Type: "worker",
    53  			Params: map[string]interface{}{
    54  				"image": "busybox",
    55  				"cmd":   []interface{}{"sleep", "1000"},
    56  			},
    57  			FullTemplate: &Template{
    58  				TemplateStr: `
    59        output: {
    60          apiVersion: "apps/v1"
    61        	kind:       "Deployment"
    62        	spec: {
    63        		selector: matchLabels: {
    64        			"app.oam.dev/component": context.name
    65        		}
    66        
    67        		template: {
    68        			metadata: labels: {
    69        				"app.oam.dev/component": context.name
    70        			}
    71        
    72        			spec: {
    73        				containers: [{
    74        					name:  context.name
    75        					image: parameter.image
    76        
    77        					if parameter["cmd"] != _|_ {
    78        						command: parameter.cmd
    79        					}
    80        				}]
    81        			}
    82        		}
    83        
    84        		selector:
    85        			matchLabels:
    86        				"app.oam.dev/component": context.name
    87        	}
    88        }
    89        
    90        parameter: {
    91        	// +usage=Which image would you like to use for your service
    92        	// +short=i
    93        	image: string
    94        
    95        	cmd?: [...string]
    96        }`,
    97  			},
    98  		},
    99  	},
   100  	WorkflowSteps: []workflowv1alpha1.WorkflowStep{
   101  		{
   102  			WorkflowStepBase: workflowv1alpha1.WorkflowStepBase{
   103  				Name: "suspend",
   104  				Type: "suspend",
   105  			},
   106  		},
   107  	},
   108  }
   109  
   110  const componentDefinition = `
   111  apiVersion: core.oam.dev/v1beta1
   112  kind: ComponentDefinition
   113  metadata:
   114    name: worker
   115    annotations:
   116      definition.oam.dev/description: "Long-running scalable backend worker without network endpoint"
   117  spec:
   118    workload:
   119      definition:
   120        apiVersion: apps/v1
   121        kind: Deployment
   122    extension:
   123      template: |
   124        output: {
   125        	apiVersion: "apps/v1"
   126        	kind:       "Deployment"
   127        	spec: {
   128        		selector: matchLabels: {
   129        			"app.oam.dev/component": context.name
   130        		}
   131  
   132        		template: {
   133        			metadata: labels: {
   134        				"app.oam.dev/component": context.name
   135        			}
   136  
   137        			spec: {
   138        				containers: [{
   139        					name:  context.name
   140        					image: parameter.image
   141  
   142        					if parameter["cmd"] != _|_ {
   143        						command: parameter.cmd
   144        					}
   145        				}]
   146        			}
   147        		}
   148  
   149        		selector:
   150        			matchLabels:
   151        				"app.oam.dev/component": context.name
   152        	}
   153        }
   154  
   155        parameter: {
   156        	// +usage=Which image would you like to use for your service
   157        	// +short=i
   158        	image: string
   159  
   160        	cmd?: [...string]
   161        }`
   162  
   163  const policyDefinition = `
   164  # Code generated by KubeVela templates. DO NOT EDIT. Please edit the original cue file.
   165  # Definition source cue file: vela-templates/definitions/internal/topology.cue
   166  apiVersion: core.oam.dev/v1beta1
   167  kind: PolicyDefinition
   168  metadata:
   169    annotations:
   170      definition.oam.dev/description: Determining the destination where components should be deployed to.
   171    name: topology
   172    namespace: {{ include "systemDefinitionNamespace" . }}
   173  spec:
   174    schematic:
   175      cue:
   176        template: |
   177          parameter: {
   178          	// +usage=Specify the names of the clusters to select.
   179          	cluster?: [...string]
   180          	// +usage=Specify the label selector for clusters
   181          	clusterLabelSelector?: [string]: string
   182          	// +usage=Deprecated: Use clusterLabelSelector instead.
   183          	clusterSelector?: [string]: string
   184          	// +usage=Specify the target namespace to deploy in the selected clusters, default inherit the original namespace.
   185          	namespace?: string
   186          }
   187  `
   188  
   189  const appfileYaml = `
   190  apiVersion: core.oam.dev/v1beta1
   191  kind: Application
   192  metadata:
   193    name: application-sample
   194    namespace: default
   195  spec:
   196    components:
   197      - name: myweb
   198        type: worker
   199        properties:
   200          image: "busybox"
   201          cmd:
   202          - sleep
   203          - "1000"
   204    workflow:
   205      steps:
   206      - name: "suspend"
   207        type: "suspend" 
   208  `
   209  
   210  const appfileYaml2 = `
   211  apiVersion: core.oam.dev/v1beta1
   212  kind: Application
   213  metadata:
   214    name: application-sample
   215    namespace: default
   216  spec:
   217    components:
   218      - name: myweb
   219        type: worker-notexist
   220        properties:
   221          image: "busybox"
   222  `
   223  
   224  const appfileYamlEmptyPolicy = `
   225  apiVersion: core.oam.dev/v1beta1
   226  kind: Application
   227  metadata:
   228    name: application-sample
   229    namespace: default
   230  spec:
   231    components: []
   232    policies:
   233      - type: garbage-collect
   234        name: somename
   235        properties:
   236  `
   237  
   238  var _ = Describe("Test application parser", func() {
   239  	It("Test parse an application", func() {
   240  		o := v1beta1.Application{}
   241  		err := yaml.Unmarshal([]byte(appfileYaml), &o)
   242  		Expect(err).ShouldNot(HaveOccurred())
   243  
   244  		// Create a mock client
   245  		tclient := test.MockClient{
   246  			MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error {
   247  				if strings.Contains(key.Name, "notexist") {
   248  					return &errors2.StatusError{ErrStatus: metav1.Status{Reason: "NotFound", Message: "not found"}}
   249  				}
   250  				switch o := obj.(type) {
   251  				case *v1beta1.ComponentDefinition:
   252  					wd, err := util.UnMarshalStringToComponentDefinition(componentDefinition)
   253  					if err != nil {
   254  						return err
   255  					}
   256  					*o = *wd
   257  				case *v1beta1.PolicyDefinition:
   258  					ppd, err := util.UnMarshalStringToPolicyDefinition(policyDefinition)
   259  					if err != nil {
   260  						return err
   261  					}
   262  					*o = *ppd
   263  				}
   264  				return nil
   265  			},
   266  		}
   267  
   268  		appfile, err := NewApplicationParser(&tclient, pd).GenerateAppFile(context.TODO(), &o)
   269  		Expect(err).ShouldNot(HaveOccurred())
   270  		Expect(equal(expectedExceptApp, appfile)).Should(BeTrue())
   271  
   272  		notfound := v1beta1.Application{}
   273  		err = yaml.Unmarshal([]byte(appfileYaml2), &notfound)
   274  		Expect(err).ShouldNot(HaveOccurred())
   275  		_, err = NewApplicationParser(&tclient, pd).GenerateAppFile(context.TODO(), &notfound)
   276  		Expect(err).Should(HaveOccurred())
   277  
   278  		By("app with empty policy")
   279  		emptyPolicy := v1beta1.Application{}
   280  		err = yaml.Unmarshal([]byte(appfileYamlEmptyPolicy), &emptyPolicy)
   281  		Expect(err).ShouldNot(HaveOccurred())
   282  		_, err = NewApplicationParser(&tclient, pd).GenerateAppFile(context.TODO(), &emptyPolicy)
   283  		Expect(err).Should(HaveOccurred())
   284  		Expect(err.Error()).Should(ContainSubstring("have empty properties"))
   285  	})
   286  })
   287  
   288  func equal(af, dest *Appfile) bool {
   289  	if af.Name != dest.Name || len(af.ParsedComponents) != len(dest.ParsedComponents) {
   290  		return false
   291  	}
   292  	for i, wd := range af.ParsedComponents {
   293  		destWd := dest.ParsedComponents[i]
   294  		if wd.Name != destWd.Name || len(wd.Traits) != len(destWd.Traits) {
   295  			return false
   296  		}
   297  		if !reflect.DeepEqual(wd.Params, destWd.Params) {
   298  			fmt.Printf("%#v | %#v\n", wd.Params, destWd.Params)
   299  			return false
   300  		}
   301  		for j, td := range wd.Traits {
   302  			destTd := destWd.Traits[j]
   303  			if td.Name != destTd.Name {
   304  				fmt.Printf("td:%s dest%s", td.Name, destTd.Name)
   305  				return false
   306  			}
   307  			if !reflect.DeepEqual(td.Params, destTd.Params) {
   308  				fmt.Printf("%#v | %#v\n", td.Params, destTd.Params)
   309  				return false
   310  			}
   311  		}
   312  	}
   313  	return true
   314  }
   315  
   316  var _ = Describe("Test application parser", func() {
   317  	var app v1beta1.Application
   318  	var apprev v1beta1.ApplicationRevision
   319  	var wsd v1beta1.WorkflowStepDefinition
   320  	var expectedExceptAppfile *Appfile
   321  	var mockClient test.MockClient
   322  
   323  	BeforeEach(func() {
   324  		// prepare WorkflowStepDefinition
   325  		Expect(common2.ReadYamlToObject("testdata/backport-1-2/wsd.yaml", &wsd)).Should(BeNil())
   326  
   327  		// prepare verify data
   328  		expectedExceptAppfile = &Appfile{
   329  			Name: "backport-1-2-test-demo",
   330  			ParsedComponents: []*Component{
   331  				{
   332  					Name: "backport-1-2-test-demo",
   333  					Type: "webservice",
   334  					Params: map[string]interface{}{
   335  						"image": "nginx",
   336  					},
   337  					FullTemplate: &Template{
   338  						TemplateStr: `
   339        output: {
   340          apiVersion: "apps/v1"
   341        	kind:       "Deployment"
   342        	spec: {
   343        		selector: matchLabels: {
   344        			"app.oam.dev/component": context.name
   345        		}
   346        
   347        		template: {
   348        			metadata: labels: {
   349        				"app.oam.dev/component": context.name
   350        			}
   351        
   352        			spec: {
   353        				containers: [{
   354        					name:  context.name
   355        					image: parameter.image
   356        
   357        					if parameter["cmd"] != _|_ {
   358        						command: parameter.cmd
   359        					}
   360        				}]
   361        			}
   362        		}
   363        
   364        		selector:
   365        			matchLabels:
   366        				"app.oam.dev/component": context.name
   367        	}
   368        }
   369        
   370        parameter: {
   371        	// +usage=Which image would you like to use for your service
   372        	// +short=i
   373        	image: string
   374        
   375        	cmd?: [...string]
   376        }`,
   377  					},
   378  					Traits: []*Trait{
   379  						{
   380  							Name: "scaler",
   381  							Params: map[string]interface{}{
   382  								"replicas": float64(1),
   383  							},
   384  							Template: `
   385  parameter: {
   386  	// +usage=Specify the number of workload
   387  	replicas: *1 | int
   388  }
   389  // +patchStrategy=retainKeys
   390  patch: spec: replicas: parameter.replicas
   391  
   392  `,
   393  						},
   394  					},
   395  				},
   396  			},
   397  			WorkflowSteps: []workflowv1alpha1.WorkflowStep{
   398  				{
   399  					WorkflowStepBase: workflowv1alpha1.WorkflowStepBase{
   400  						Name: "apply",
   401  						Type: "apply-application",
   402  					},
   403  				},
   404  			},
   405  		}
   406  
   407  		// Create mock client
   408  		mockClient = test.MockClient{
   409  			MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error {
   410  				if strings.Contains(key.Name, "unknown") {
   411  					return &errors2.StatusError{ErrStatus: metav1.Status{Reason: "NotFound", Message: "not found"}}
   412  				}
   413  				switch o := obj.(type) {
   414  				case *v1beta1.ComponentDefinition:
   415  					wd, err := util.UnMarshalStringToComponentDefinition(componentDefinition)
   416  					if err != nil {
   417  						return err
   418  					}
   419  					*o = *wd
   420  				case *v1beta1.WorkflowStepDefinition:
   421  					*o = wsd
   422  				case *v1beta1.ApplicationRevision:
   423  					*o = apprev
   424  				default:
   425  					// skip
   426  				}
   427  				return nil
   428  			},
   429  		}
   430  	})
   431  
   432  	When("with apply-application workflowStep", func() {
   433  		BeforeEach(func() {
   434  			// prepare application
   435  			Expect(common2.ReadYamlToObject("testdata/backport-1-2/app.yaml", &app)).Should(BeNil())
   436  			// prepare application revision
   437  			Expect(common2.ReadYamlToObject("testdata/backport-1-2/apprev1.yaml", &apprev)).Should(BeNil())
   438  		})
   439  
   440  		It("Test we can parse an application revision to an appFile 1", func() {
   441  
   442  			appfile, err := NewApplicationParser(&mockClient, pd).GenerateAppFile(context.TODO(), &app)
   443  			Expect(err).ShouldNot(HaveOccurred())
   444  			Expect(equal(expectedExceptAppfile, appfile)).Should(BeTrue())
   445  			Expect(len(appfile.WorkflowSteps) > 0 &&
   446  				len(appfile.RelatedWorkflowStepDefinitions) == len(appfile.AppRevision.Spec.WorkflowStepDefinitions)).Should(BeTrue())
   447  
   448  			Expect(len(appfile.WorkflowSteps) > 0 && func() bool {
   449  				this := appfile.RelatedWorkflowStepDefinitions
   450  				that := appfile.AppRevision.Spec.WorkflowStepDefinitions
   451  				for i, w := range this {
   452  					thatW := that[i]
   453  					if !reflect.DeepEqual(w, thatW) {
   454  						return false
   455  					}
   456  				}
   457  				return true
   458  			}()).Should(BeTrue())
   459  		})
   460  	})
   461  
   462  	When("with apply-application and apply-component build-in workflowStep", func() {
   463  		BeforeEach(func() {
   464  			// prepare application
   465  			Expect(common2.ReadYamlToObject("testdata/backport-1-2/app.yaml", &app)).Should(BeNil())
   466  			// prepare application revision
   467  			Expect(common2.ReadYamlToObject("testdata/backport-1-2/apprev2.yaml", &apprev)).Should(BeNil())
   468  		})
   469  
   470  		It("Test we can parse an application revision to an appFile 2", func() {
   471  
   472  			appfile, err := NewApplicationParser(&mockClient, pd).GenerateAppFile(context.TODO(), &app)
   473  			Expect(err).ShouldNot(HaveOccurred())
   474  			Expect(equal(expectedExceptAppfile, appfile)).Should(BeTrue())
   475  			Expect(len(appfile.WorkflowSteps) > 0 &&
   476  				len(appfile.RelatedWorkflowStepDefinitions) == len(appfile.AppRevision.Spec.WorkflowStepDefinitions)).Should(BeTrue())
   477  
   478  			Expect(len(appfile.WorkflowSteps) > 0 && func() bool {
   479  				this := appfile.RelatedWorkflowStepDefinitions
   480  				that := appfile.AppRevision.Spec.WorkflowStepDefinitions
   481  				for i, w := range this {
   482  					thatW := that[i]
   483  					if !reflect.DeepEqual(w, thatW) {
   484  						fmt.Printf("appfile wsd:%s apprev wsd%s", (*w).Name, thatW.Name)
   485  						return false
   486  					}
   487  				}
   488  				return true
   489  			}()).Should(BeTrue())
   490  		})
   491  	})
   492  
   493  	When("with unknown workflowStep", func() {
   494  		BeforeEach(func() {
   495  			// prepare application
   496  			Expect(common2.ReadYamlToObject("testdata/backport-1-2/app.yaml", &app)).Should(BeNil())
   497  			// prepare application revision
   498  			Expect(common2.ReadYamlToObject("testdata/backport-1-2/apprev3.yaml", &apprev)).Should(BeNil())
   499  		})
   500  
   501  		It("Test we can parse an application revision to an appFile 3", func() {
   502  
   503  			_, err := NewApplicationParser(&mockClient, pd).GenerateAppFile(context.TODO(), &app)
   504  			Expect(err).Should(HaveOccurred())
   505  			Expect(err.Error()).Should(SatisfyAll(
   506  				ContainSubstring("failed to get workflow step definition apply-application-unknown: not found"),
   507  				ContainSubstring("failed to parseWorkflowStepsForLegacyRevision")),
   508  			)
   509  		})
   510  	})
   511  })
   512  
   513  func TestParser_parseTraits(t *testing.T) {
   514  	type args struct {
   515  		workload *Component
   516  		comp     common.ApplicationComponent
   517  	}
   518  	tests := []struct {
   519  		name                 string
   520  		args                 args
   521  		wantErr              assert.ErrorAssertionFunc
   522  		mockTemplateLoaderFn TemplateLoaderFn
   523  		validateFunc         func(w *Component) bool
   524  	}{
   525  		{
   526  			name: "test empty traits",
   527  			args: args{
   528  				comp: common.ApplicationComponent{},
   529  			},
   530  			wantErr: assert.NoError,
   531  		},
   532  		{
   533  			name: "test parse trait properties error",
   534  			args: args{
   535  				comp: common.ApplicationComponent{
   536  					Traits: []common.ApplicationTrait{
   537  						{
   538  							Type: "expose",
   539  							Properties: &runtime.RawExtension{
   540  								Raw: []byte("invalid properties"),
   541  							},
   542  						},
   543  					},
   544  				},
   545  			},
   546  			wantErr: assert.Error,
   547  		},
   548  		{
   549  			name: "test parse trait error",
   550  			args: args{
   551  				comp: common.ApplicationComponent{
   552  					Traits: []common.ApplicationTrait{
   553  						{
   554  							Type: "expose",
   555  							Properties: &runtime.RawExtension{
   556  								Raw: []byte(`{"unsupported": "{\"key\":\"value\"}"}`),
   557  							},
   558  						},
   559  					},
   560  				},
   561  			},
   562  			mockTemplateLoaderFn: func(context.Context, client.Client, string, types.CapType) (*Template, error) {
   563  				return nil, fmt.Errorf("unsupported key not found")
   564  			},
   565  			wantErr: assert.Error,
   566  		},
   567  		{
   568  			name: "test parse trait success",
   569  			args: args{
   570  				comp: common.ApplicationComponent{
   571  					Traits: []common.ApplicationTrait{
   572  						{
   573  							Type: "expose",
   574  							Properties: &runtime.RawExtension{
   575  								Raw: []byte(`{"annotation": "{\"key\":\"value\"}"}`),
   576  							},
   577  						},
   578  					},
   579  				},
   580  				workload: &Component{},
   581  			},
   582  			wantErr: assert.NoError,
   583  			mockTemplateLoaderFn: func(ctx context.Context, reader client.Client, s string, capType types.CapType) (*Template, error) {
   584  				return &Template{
   585  					TemplateStr:        "template",
   586  					CapabilityCategory: "network",
   587  					Health:             "true",
   588  					CustomStatus:       "healthy",
   589  				}, nil
   590  			},
   591  			validateFunc: func(w *Component) bool {
   592  				return w != nil && len(w.Traits) != 0 && w.Traits[0].Name == "expose" && w.Traits[0].Template == "template"
   593  			},
   594  		},
   595  	}
   596  
   597  	p := NewApplicationParser(nil, pd)
   598  	for _, tt := range tests {
   599  		t.Run(tt.name, func(t *testing.T) {
   600  			p.tmplLoader = tt.mockTemplateLoaderFn
   601  			err := p.parseTraits(context.Background(), tt.args.workload, tt.args.comp)
   602  			tt.wantErr(t, err, fmt.Sprintf("parseTraits(%v, %v)", tt.args.workload, tt.args.comp))
   603  			if tt.validateFunc != nil {
   604  				assert.True(t, tt.validateFunc(tt.args.workload))
   605  			}
   606  		})
   607  	}
   608  }
   609  
   610  func TestParser_parseTraitsFromRevision(t *testing.T) {
   611  	type args struct {
   612  		comp     common.ApplicationComponent
   613  		appRev   *v1beta1.ApplicationRevision
   614  		workload *Component
   615  	}
   616  	tests := []struct {
   617  		name         string
   618  		args         args
   619  		validateFunc func(w *Component) bool
   620  		wantErr      assert.ErrorAssertionFunc
   621  	}{
   622  		{
   623  			name: "test empty traits",
   624  			args: args{
   625  				comp: common.ApplicationComponent{},
   626  			},
   627  			wantErr: assert.NoError,
   628  		},
   629  		{
   630  			name: "test parse traits properties error",
   631  			args: args{
   632  				comp: common.ApplicationComponent{
   633  					Traits: []common.ApplicationTrait{
   634  						{
   635  							Type:       "expose",
   636  							Properties: &runtime.RawExtension{Raw: []byte("invalid")},
   637  						},
   638  					},
   639  				},
   640  				workload: &Component{},
   641  			},
   642  			wantErr: assert.Error,
   643  		},
   644  		{
   645  			name: "test parse traits from revision failed",
   646  			args: args{
   647  				comp: common.ApplicationComponent{
   648  					Traits: []common.ApplicationTrait{
   649  						{
   650  							Type:       "expose",
   651  							Properties: &runtime.RawExtension{Raw: []byte(`{"appRevisionName": "appRevName"}`)},
   652  						},
   653  					},
   654  				},
   655  				appRev: &v1beta1.ApplicationRevision{
   656  					Spec: v1beta1.ApplicationRevisionSpec{
   657  						ApplicationRevisionCompressibleFields: v1beta1.ApplicationRevisionCompressibleFields{
   658  							TraitDefinitions: map[string]*v1beta1.TraitDefinition{},
   659  						},
   660  					},
   661  				},
   662  				workload: &Component{},
   663  			},
   664  			wantErr: assert.Error,
   665  		},
   666  		{
   667  			name: "test parse traits from revision success",
   668  			args: args{
   669  				comp: common.ApplicationComponent{
   670  					Traits: []common.ApplicationTrait{
   671  						{
   672  							Type:       "expose",
   673  							Properties: &runtime.RawExtension{Raw: []byte(`{"appRevisionName": "appRevName"}`)},
   674  						},
   675  					},
   676  				},
   677  				appRev: &v1beta1.ApplicationRevision{
   678  					Spec: v1beta1.ApplicationRevisionSpec{
   679  						ApplicationRevisionCompressibleFields: v1beta1.ApplicationRevisionCompressibleFields{
   680  							TraitDefinitions: map[string]*v1beta1.TraitDefinition{
   681  								"expose": {
   682  									Spec: v1beta1.TraitDefinitionSpec{
   683  										RevisionEnabled:    true,
   684  										AppliesToWorkloads: []string{"*"},
   685  									},
   686  								},
   687  							},
   688  						},
   689  					},
   690  				},
   691  				workload: &Component{},
   692  			},
   693  			wantErr: assert.NoError,
   694  			validateFunc: func(w *Component) bool {
   695  				return w != nil && len(w.Traits) == 1 && w.Traits[0].Name == "expose"
   696  			},
   697  		},
   698  	}
   699  	p := NewApplicationParser(fake.NewClientBuilder().Build(), pd)
   700  	for _, tt := range tests {
   701  		t.Run(tt.name, func(t *testing.T) {
   702  			tt.wantErr(t, p.parseTraitsFromRevision(tt.args.comp, tt.args.appRev, tt.args.workload), fmt.Sprintf("parseTraitsFromRevision(%v, %v, %v)", tt.args.comp, tt.args.appRev, tt.args.workload))
   703  			if tt.validateFunc != nil {
   704  				assert.True(t, tt.validateFunc(tt.args.workload))
   705  			}
   706  		})
   707  	}
   708  }