github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/controllers/core/cmd/controller_test.go (about)

     1  package cmd
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io"
     7  	"strconv"
     8  	"strings"
     9  	"testing"
    10  	"time"
    11  
    12  	"github.com/jonboulle/clockwork"
    13  	"github.com/stretchr/testify/assert"
    14  	"github.com/stretchr/testify/require"
    15  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    16  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    17  	"k8s.io/apimachinery/pkg/types"
    18  	"k8s.io/utils/pointer"
    19  	ctrl "sigs.k8s.io/controller-runtime"
    20  	"sigs.k8s.io/controller-runtime/pkg/reconcile"
    21  
    22  	"github.com/tilt-dev/tilt/internal/controllers/fake"
    23  	"github.com/tilt-dev/tilt/internal/engine/local"
    24  	"github.com/tilt-dev/tilt/internal/store"
    25  	"github.com/tilt-dev/tilt/internal/testutils/configmap"
    26  	"github.com/tilt-dev/tilt/pkg/apis"
    27  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    28  	"github.com/tilt-dev/tilt/pkg/model"
    29  )
    30  
    31  var timeout = time.Second
    32  var interval = 5 * time.Millisecond
    33  
    34  func TestNoop(t *testing.T) {
    35  	f := newFixture(t)
    36  
    37  	f.step()
    38  	f.assertCmdCount(0)
    39  }
    40  
    41  func TestUpdate(t *testing.T) {
    42  	f := newFixture(t)
    43  
    44  	t1 := time.Unix(1, 0)
    45  	f.resource("foo", "true", ".", t1)
    46  	f.step()
    47  	f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool {
    48  		return cmd.Status.Running != nil
    49  	})
    50  
    51  	t2 := time.Unix(2, 0)
    52  	f.resource("foo", "false", ".", t2)
    53  	f.step()
    54  	f.assertCmdDeleted("foo-serve-1")
    55  
    56  	f.step()
    57  	f.assertCmdMatches("foo-serve-2", func(cmd *Cmd) bool {
    58  		return cmd.Status.Running != nil
    59  	})
    60  
    61  	f.fe.RequireNoKnownProcess(t, "true")
    62  	f.assertLogMessage("foo", "Starting cmd false")
    63  	f.assertLogMessage("foo", "cmd true canceled")
    64  	f.assertCmdCount(1)
    65  }
    66  
    67  func TestUpdateWithCurrentBuild(t *testing.T) {
    68  	f := newFixture(t)
    69  
    70  	t1 := time.Unix(1, 0)
    71  	f.resource("foo", "true", ".", t1)
    72  	f.step()
    73  	f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool {
    74  		return cmd.Status.Running != nil
    75  	})
    76  
    77  	f.st.WithState(func(s *store.EngineState) {
    78  		c := model.ToHostCmd("false")
    79  		localTarget := model.NewLocalTarget(model.TargetName("foo"), c, c, nil)
    80  		s.ManifestTargets["foo"].Manifest.DeployTarget = localTarget
    81  		s.ManifestTargets["foo"].State.CurrentBuilds["buildcontrol"] = model.BuildRecord{StartTime: f.clock.Now()}
    82  	})
    83  
    84  	f.step()
    85  
    86  	assert.Never(t, func() bool {
    87  		return f.st.Cmd("foo-serve-2") != nil
    88  	}, 20*time.Millisecond, 5*time.Millisecond)
    89  
    90  	f.st.WithState(func(s *store.EngineState) {
    91  		delete(s.ManifestTargets["foo"].State.CurrentBuilds, "buildcontrol")
    92  	})
    93  
    94  	f.step()
    95  	f.assertCmdDeleted("foo-serve-1")
    96  }
    97  
    98  func TestServe(t *testing.T) {
    99  	f := newFixture(t)
   100  
   101  	t1 := time.Unix(1, 0)
   102  	f.resource("foo", "sleep 60", "testdir", t1)
   103  	f.step()
   104  	f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool {
   105  		return cmd.Status.Running != nil && cmd.Status.Ready
   106  	})
   107  
   108  	require.Equal(t, "testdir", f.fe.processes["sleep 60"].workdir)
   109  
   110  	f.assertLogMessage("foo", "Starting cmd sleep 60")
   111  }
   112  
   113  func TestServeReadinessProbe(t *testing.T) {
   114  	f := newFixture(t)
   115  
   116  	t1 := time.Unix(1, 0)
   117  
   118  	c := model.ToHostCmdInDir("sleep 60", "testdir")
   119  	localTarget := model.NewLocalTarget("foo", model.Cmd{}, c, nil)
   120  	localTarget.ReadinessProbe = &v1alpha1.Probe{
   121  		TimeoutSeconds: 5,
   122  		Handler: v1alpha1.Handler{
   123  			Exec: &v1alpha1.ExecAction{Command: []string{"sleep", "15"}},
   124  		},
   125  	}
   126  
   127  	f.resourceFromTarget("foo", localTarget, t1)
   128  	f.step()
   129  	f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool {
   130  		return cmd.Status.Running != nil && cmd.Status.Ready
   131  	})
   132  	f.assertLogMessage("foo", "[readiness probe: success] fake probe succeeded")
   133  
   134  	assert.Equal(t, "sleep", f.fpm.execName)
   135  	assert.Equal(t, []string{"15"}, f.fpm.execArgs)
   136  	assert.GreaterOrEqual(t, f.fpm.ProbeCount(), 1)
   137  }
   138  
   139  func TestServeReadinessProbeInvalidSpec(t *testing.T) {
   140  	f := newFixture(t)
   141  
   142  	t1 := time.Unix(1, 0)
   143  
   144  	c := model.ToHostCmdInDir("sleep 60", "testdir")
   145  	localTarget := model.NewLocalTarget("foo", model.Cmd{}, c, nil)
   146  	localTarget.ReadinessProbe = &v1alpha1.Probe{
   147  		Handler: v1alpha1.Handler{HTTPGet: &v1alpha1.HTTPGetAction{
   148  			// port > 65535
   149  			Port: 70000,
   150  		}},
   151  	}
   152  
   153  	f.resourceFromTarget("foo", localTarget, t1)
   154  	f.step()
   155  
   156  	f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool {
   157  		return cmd.Status.Terminated != nil && cmd.Status.Terminated.ExitCode == 1
   158  	})
   159  
   160  	f.assertLogMessage("foo", "Invalid readiness probe: port number out of range: 70000")
   161  	assert.Equal(t, 0, f.fpm.ProbeCount())
   162  }
   163  
   164  func TestFailure(t *testing.T) {
   165  	f := newFixture(t)
   166  
   167  	t1 := time.Unix(1, 0)
   168  	f.resource("foo", "true", ".", t1)
   169  	f.step()
   170  	f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool {
   171  		return cmd.Status.Running != nil
   172  	})
   173  
   174  	f.assertLogMessage("foo", "Starting cmd true")
   175  
   176  	err := f.fe.stop("true", 5)
   177  	require.NoError(t, err)
   178  	f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool {
   179  		return cmd.Status.Terminated != nil && cmd.Status.Terminated.ExitCode == 5
   180  	})
   181  
   182  	f.assertLogMessage("foo", "cmd true exited with code 5")
   183  }
   184  
   185  func TestUniqueSpanIDs(t *testing.T) {
   186  	f := newFixture(t)
   187  
   188  	t1 := time.Unix(1, 0)
   189  	f.resource("foo", "foo.sh", ".", t1)
   190  	f.resource("bar", "bar.sh", ".", t1)
   191  	f.step()
   192  
   193  	fooStart := f.waitForLogEventContaining("Starting cmd foo.sh")
   194  	barStart := f.waitForLogEventContaining("Starting cmd bar.sh")
   195  	require.NotEqual(t, fooStart.SpanID(), barStart.SpanID(), "different resources should have unique log span ids")
   196  }
   197  
   198  func TestTearDown(t *testing.T) {
   199  	f := newFixture(t)
   200  
   201  	t1 := time.Unix(1, 0)
   202  	f.resource("foo", "foo.sh", ".", t1)
   203  	f.resource("bar", "bar.sh", ".", t1)
   204  	f.step()
   205  
   206  	f.c.TearDown(f.Context())
   207  
   208  	f.fe.RequireNoKnownProcess(t, "foo.sh")
   209  	f.fe.RequireNoKnownProcess(t, "bar.sh")
   210  }
   211  
   212  func TestRestartOnFileWatch(t *testing.T) {
   213  	f := newFixture(t)
   214  
   215  	f.resource("cmd", "true", ".", f.clock.Now())
   216  	f.step()
   217  
   218  	firstStart := f.assertCmdMatches("cmd-serve-1", func(cmd *Cmd) bool {
   219  		return cmd.Status.Running != nil
   220  	})
   221  
   222  	fw := &FileWatch{
   223  		ObjectMeta: ObjectMeta{
   224  			Name: "fw-1",
   225  		},
   226  		Spec: FileWatchSpec{
   227  			WatchedPaths: []string{t.TempDir()},
   228  		},
   229  	}
   230  	err := f.Client.Create(f.Context(), fw)
   231  	require.NoError(t, err)
   232  
   233  	f.clock.Advance(time.Second)
   234  	f.updateSpec("cmd-serve-1", func(spec *v1alpha1.CmdSpec) {
   235  		spec.RestartOn = &RestartOnSpec{
   236  			FileWatches: []string{"fw-1"},
   237  		}
   238  	})
   239  
   240  	f.clock.Advance(time.Second)
   241  	f.triggerFileWatch("fw-1")
   242  	f.reconcileCmd("cmd-serve-1")
   243  
   244  	f.assertCmdMatches("cmd-serve-1", func(cmd *Cmd) bool {
   245  		running := cmd.Status.Running
   246  		return running != nil && running.StartedAt.Time.After(firstStart.Status.Running.StartedAt.Time)
   247  	})
   248  
   249  	// Our fixture doesn't test reconcile.Request triage,
   250  	// so test it manually here.
   251  	assert.Equal(f.T(),
   252  		[]reconcile.Request{
   253  			reconcile.Request{NamespacedName: types.NamespacedName{Name: "cmd-serve-1"}},
   254  		},
   255  		f.c.indexer.Enqueue(context.Background(), fw))
   256  }
   257  
   258  func TestRestartOnUIButton(t *testing.T) {
   259  	f := newFixture(t)
   260  
   261  	f.resource("cmd", "true", ".", f.clock.Now())
   262  	f.step()
   263  
   264  	firstStart := f.assertCmdMatches("cmd-serve-1", func(cmd *Cmd) bool {
   265  		return cmd.Status.Running != nil
   266  	})
   267  
   268  	f.clock.Advance(time.Second)
   269  	f.updateSpec("cmd-serve-1", func(spec *v1alpha1.CmdSpec) {
   270  		spec.RestartOn = &RestartOnSpec{
   271  			UIButtons: []string{"b-1"},
   272  		}
   273  	})
   274  
   275  	b := &UIButton{
   276  		ObjectMeta: ObjectMeta{
   277  			Name: "b-1",
   278  		},
   279  		Spec: UIButtonSpec{},
   280  	}
   281  	err := f.Client.Create(f.Context(), b)
   282  	require.NoError(t, err)
   283  
   284  	f.clock.Advance(time.Second)
   285  	f.triggerButton("b-1", f.clock.Now())
   286  	f.reconcileCmd("cmd-serve-1")
   287  
   288  	f.assertCmdMatches("cmd-serve-1", func(cmd *Cmd) bool {
   289  		running := cmd.Status.Running
   290  		return running != nil && running.StartedAt.Time.After(firstStart.Status.Running.StartedAt.Time)
   291  	})
   292  
   293  	// Our fixture doesn't test reconcile.Request triage,
   294  	// so test it manually here.
   295  	assert.Equal(f.T(),
   296  		[]reconcile.Request{
   297  			reconcile.Request{NamespacedName: types.NamespacedName{Name: "cmd-serve-1"}},
   298  		},
   299  		f.c.indexer.Enqueue(context.Background(), b))
   300  }
   301  
   302  func setupStartOnTest(t *testing.T, f *fixture) {
   303  	cmd := &Cmd{
   304  		ObjectMeta: metav1.ObjectMeta{
   305  			Name: "testcmd",
   306  		},
   307  		Spec: v1alpha1.CmdSpec{
   308  			Args: []string{"myserver"},
   309  			StartOn: &StartOnSpec{
   310  				UIButtons:  []string{"b-1"},
   311  				StartAfter: apis.NewTime(f.clock.Now()),
   312  			},
   313  		},
   314  	}
   315  
   316  	err := f.Client.Create(f.Context(), cmd)
   317  	require.NoError(t, err)
   318  
   319  	b := &UIButton{
   320  		ObjectMeta: ObjectMeta{
   321  			Name: "b-1",
   322  		},
   323  		Spec: UIButtonSpec{},
   324  	}
   325  	err = f.Client.Create(f.Context(), b)
   326  	require.NoError(t, err)
   327  
   328  	f.reconcileCmd("testcmd")
   329  
   330  	f.fe.RequireNoKnownProcess(t, "myserver")
   331  }
   332  
   333  func TestStartOnNoPreviousProcess(t *testing.T) {
   334  	f := newFixture(t)
   335  
   336  	startup := f.clock.Now()
   337  
   338  	setupStartOnTest(t, f)
   339  
   340  	f.clock.Advance(time.Second)
   341  
   342  	f.triggerButton("b-1", f.clock.Now())
   343  	f.reconcileCmd("testcmd")
   344  
   345  	f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool {
   346  		running := cmd.Status.Running
   347  		return running != nil && running.StartedAt.Time.After(startup)
   348  	})
   349  }
   350  
   351  func TestStartOnDoesntRunOnCreation(t *testing.T) {
   352  	f := newFixture(t)
   353  
   354  	setupStartOnTest(t, f)
   355  
   356  	f.reconcileCmd("testcmd")
   357  
   358  	f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool {
   359  		return cmd.Status.Waiting != nil && cmd.Status.Waiting.Reason == waitingOnStartOnReason
   360  	})
   361  
   362  	f.fe.RequireNoKnownProcess(t, "myserver")
   363  }
   364  
   365  func TestStartOnStartAfter(t *testing.T) {
   366  	f := newFixture(t)
   367  
   368  	setupStartOnTest(t, f)
   369  
   370  	f.triggerButton("b-1", f.clock.Now().Add(-time.Minute))
   371  
   372  	f.reconcileCmd("testcmd")
   373  
   374  	f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool {
   375  		return cmd.Status.Waiting != nil && cmd.Status.Waiting.Reason == waitingOnStartOnReason
   376  	})
   377  
   378  	f.fe.RequireNoKnownProcess(t, "myserver")
   379  }
   380  
   381  func TestStartOnRunningProcess(t *testing.T) {
   382  	f := newFixture(t)
   383  
   384  	setupStartOnTest(t, f)
   385  
   386  	f.clock.Advance(time.Second)
   387  	f.triggerButton("b-1", f.clock.Now())
   388  	f.reconcileCmd("testcmd")
   389  
   390  	// wait for the initial process to start
   391  	f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool {
   392  		return cmd.Status.Running != nil
   393  	})
   394  
   395  	f.fe.mu.Lock()
   396  	st := f.fe.processes["myserver"].startTime
   397  	f.fe.mu.Unlock()
   398  
   399  	f.clock.Advance(time.Second)
   400  
   401  	secondClickTime := f.clock.Now()
   402  	f.triggerButton("b-1", secondClickTime)
   403  	f.reconcileCmd("testcmd")
   404  
   405  	f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool {
   406  		running := cmd.Status.Running
   407  		return running != nil && !running.StartedAt.Time.Before(secondClickTime)
   408  	})
   409  
   410  	// make sure it's not the same process
   411  	f.fe.mu.Lock()
   412  	p, ok := f.fe.processes["myserver"]
   413  	require.True(t, ok)
   414  	require.NotEqual(t, st, p.startTime)
   415  	f.fe.mu.Unlock()
   416  }
   417  
   418  func TestStartOnPreviousTerminatedProcess(t *testing.T) {
   419  	f := newFixture(t)
   420  
   421  	firstClickTime := f.clock.Now()
   422  
   423  	setupStartOnTest(t, f)
   424  
   425  	f.triggerButton("b-1", firstClickTime)
   426  	f.reconcileCmd("testcmd")
   427  
   428  	// wait for the initial process to start
   429  	f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool {
   430  		return cmd.Status.Running != nil
   431  	})
   432  
   433  	f.fe.mu.Lock()
   434  	st := f.fe.processes["myserver"].startTime
   435  	f.fe.mu.Unlock()
   436  
   437  	err := f.fe.stop("myserver", 1)
   438  	require.NoError(t, err)
   439  
   440  	// wait for the initial process to die
   441  	f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool {
   442  		return cmd.Status.Terminated != nil
   443  	})
   444  
   445  	f.clock.Advance(time.Second)
   446  	secondClickTime := f.clock.Now()
   447  	f.triggerButton("b-1", secondClickTime)
   448  	f.reconcileCmd("testcmd")
   449  
   450  	f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool {
   451  		running := cmd.Status.Running
   452  		return running != nil && !running.StartedAt.Time.Before(secondClickTime)
   453  	})
   454  
   455  	// make sure it's not the same process
   456  	f.fe.mu.Lock()
   457  	p, ok := f.fe.processes["myserver"]
   458  	require.True(t, ok)
   459  	require.NotEqual(t, st, p.startTime)
   460  	f.fe.mu.Unlock()
   461  }
   462  
   463  func TestDisposeOrphans(t *testing.T) {
   464  	f := newFixture(t)
   465  
   466  	t1 := time.Unix(1, 0)
   467  	f.resource("foo", "true", ".", t1)
   468  	f.step()
   469  	f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool {
   470  		return cmd.Status.Running != nil
   471  	})
   472  
   473  	f.st.WithState(func(es *store.EngineState) {
   474  		es.RemoveManifestTarget("foo")
   475  	})
   476  	f.step()
   477  	f.assertCmdCount(0)
   478  	f.fe.RequireNoKnownProcess(t, "true")
   479  }
   480  
   481  func TestDisposeTerminatedWhenCmdChanges(t *testing.T) {
   482  	f := newFixture(t)
   483  
   484  	t1 := time.Unix(1, 0)
   485  	f.resource("foo", "true", ".", t1)
   486  	f.step()
   487  
   488  	f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool {
   489  		return cmd.Status.Running != nil
   490  	})
   491  
   492  	err := f.fe.stop("true", 0)
   493  	require.NoError(t, err)
   494  
   495  	f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool {
   496  		return cmd.Status.Terminated != nil
   497  	})
   498  
   499  	f.resource("foo", "true", "subdir", t1)
   500  	f.step()
   501  	f.assertCmdMatches("foo-serve-2", func(cmd *Cmd) bool {
   502  		return cmd.Status.Running != nil
   503  	})
   504  	f.assertCmdDeleted("foo-serve-1")
   505  }
   506  
   507  func TestDisableCmd(t *testing.T) {
   508  	f := newFixture(t)
   509  
   510  	cmd := &Cmd{
   511  		ObjectMeta: metav1.ObjectMeta{
   512  			Name: "cmd-1",
   513  		},
   514  		Spec: v1alpha1.CmdSpec{
   515  			Args: []string{"sh", "-c", "sleep 10000"},
   516  			DisableSource: &v1alpha1.DisableSource{
   517  				ConfigMap: &v1alpha1.ConfigMapDisableSource{
   518  					Name: "disable-cmd-1",
   519  					Key:  "isDisabled",
   520  				},
   521  			},
   522  		},
   523  	}
   524  	err := f.Client.Create(f.Context(), cmd)
   525  	require.NoError(t, err)
   526  
   527  	f.setDisabled(cmd.Name, false)
   528  
   529  	f.requireCmdMatchesInAPI(cmd.Name, func(cmd *Cmd) bool {
   530  		return cmd.Status.Running != nil &&
   531  			cmd.Status.DisableStatus != nil &&
   532  			cmd.Status.DisableStatus.State == v1alpha1.DisableStateEnabled
   533  	})
   534  
   535  	f.setDisabled(cmd.Name, true)
   536  
   537  	f.requireCmdMatchesInAPI(cmd.Name, func(cmd *Cmd) bool {
   538  		return cmd.Status.Terminated != nil &&
   539  			cmd.Status.DisableStatus != nil &&
   540  			cmd.Status.DisableStatus.State == v1alpha1.DisableStateDisabled
   541  	})
   542  
   543  	f.setDisabled(cmd.Name, false)
   544  
   545  	f.requireCmdMatchesInAPI(cmd.Name, func(cmd *Cmd) bool {
   546  		return cmd.Status.Running != nil &&
   547  			cmd.Status.DisableStatus != nil &&
   548  			cmd.Status.DisableStatus.State == v1alpha1.DisableStateEnabled
   549  	})
   550  }
   551  
   552  func TestReenable(t *testing.T) {
   553  	f := newFixture(t)
   554  
   555  	cmd := &Cmd{
   556  		ObjectMeta: metav1.ObjectMeta{
   557  			Name: "cmd-1",
   558  		},
   559  		Spec: v1alpha1.CmdSpec{
   560  			Args: []string{"sh", "-c", "sleep 10000"},
   561  			DisableSource: &v1alpha1.DisableSource{
   562  				ConfigMap: &v1alpha1.ConfigMapDisableSource{
   563  					Name: "disable-cmd-1",
   564  					Key:  "isDisabled",
   565  				},
   566  			},
   567  		},
   568  	}
   569  	err := f.Client.Create(f.Context(), cmd)
   570  	require.NoError(t, err)
   571  
   572  	f.setDisabled(cmd.Name, true)
   573  
   574  	f.requireCmdMatchesInAPI(cmd.Name, func(cmd *Cmd) bool {
   575  		return cmd.Status.Running == nil &&
   576  			cmd.Status.DisableStatus != nil &&
   577  			cmd.Status.DisableStatus.State == v1alpha1.DisableStateDisabled
   578  	})
   579  
   580  	f.setDisabled(cmd.Name, false)
   581  
   582  	f.requireCmdMatchesInAPI(cmd.Name, func(cmd *Cmd) bool {
   583  		return cmd.Status.Running != nil &&
   584  			cmd.Status.DisableStatus != nil &&
   585  			cmd.Status.DisableStatus.State == v1alpha1.DisableStateEnabled
   586  	})
   587  }
   588  
   589  func TestDisableServeCmd(t *testing.T) {
   590  	f := newFixture(t)
   591  
   592  	ds := v1alpha1.DisableSource{ConfigMap: &v1alpha1.ConfigMapDisableSource{Name: "disable-foo", Key: "isDisabled"}}
   593  	t1 := time.Unix(1, 0)
   594  	localTarget := model.NewLocalTarget("foo", model.Cmd{}, model.ToHostCmd("."), nil)
   595  	localTarget.ServeCmdDisableSource = &ds
   596  	err := configmap.UpsertDisableConfigMap(f.Context(), f.Client, ds.ConfigMap.Name, ds.ConfigMap.Key, false)
   597  	require.NoError(t, err)
   598  
   599  	f.resourceFromTarget("foo", localTarget, t1)
   600  
   601  	f.step()
   602  	f.requireCmdMatchesInAPI("foo-serve-1", func(cmd *Cmd) bool {
   603  		return cmd != nil && cmd.Status.Running != nil
   604  	})
   605  
   606  	err = configmap.UpsertDisableConfigMap(f.Context(), f.Client, ds.ConfigMap.Name, ds.ConfigMap.Key, true)
   607  	require.NoError(t, err)
   608  
   609  	f.step()
   610  	f.assertCmdCount(0)
   611  }
   612  
   613  func TestEnableServeCmd(t *testing.T) {
   614  	f := newFixture(t)
   615  
   616  	ds := v1alpha1.DisableSource{ConfigMap: &v1alpha1.ConfigMapDisableSource{Name: "disable-foo", Key: "isDisabled"}}
   617  	err := configmap.UpsertDisableConfigMap(f.Context(), f.Client, ds.ConfigMap.Name, ds.ConfigMap.Key, true)
   618  	require.NoError(t, err)
   619  
   620  	t1 := time.Unix(1, 0)
   621  	localTarget := model.NewLocalTarget("foo", model.Cmd{}, model.ToHostCmd("."), nil)
   622  	localTarget.ServeCmdDisableSource = &ds
   623  	f.resourceFromTarget("foo", localTarget, t1)
   624  
   625  	f.step()
   626  	f.assertCmdCount(0)
   627  	err = configmap.UpsertDisableConfigMap(f.Context(), f.Client, ds.ConfigMap.Name, ds.ConfigMap.Key, false)
   628  	require.NoError(t, err)
   629  
   630  	f.step()
   631  	f.requireCmdMatchesInAPI("foo-serve-1", func(cmd *Cmd) bool {
   632  		return cmd != nil && cmd.Status.Running != nil
   633  	})
   634  }
   635  
   636  // Self-modifying Cmds are typically paired with a StartOn trigger,
   637  // to simulate a "toggle" switch on the Cmd.
   638  //
   639  // See:
   640  // https://github.com/tilt-dev/tilt-extensions/issues/202
   641  func TestSelfModifyingCmd(t *testing.T) {
   642  	f := newFixture(t)
   643  
   644  	setupStartOnTest(t, f)
   645  
   646  	f.reconcileCmd("testcmd")
   647  
   648  	f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool {
   649  		return cmd.Status.Waiting != nil && cmd.Status.Waiting.Reason == waitingOnStartOnReason
   650  	})
   651  
   652  	f.clock.Advance(time.Second)
   653  	f.triggerButton("b-1", f.clock.Now())
   654  	f.clock.Advance(time.Second)
   655  	f.reconcileCmd("testcmd")
   656  
   657  	f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool {
   658  		return cmd.Status.Running != nil
   659  	})
   660  
   661  	f.updateSpec("testcmd", func(spec *v1alpha1.CmdSpec) {
   662  		spec.Args = []string{"yourserver"}
   663  	})
   664  	f.reconcileCmd("testcmd")
   665  	f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool {
   666  		return cmd.Status.Waiting != nil && cmd.Status.Waiting.Reason == waitingOnStartOnReason
   667  	})
   668  
   669  	f.fe.RequireNoKnownProcess(t, "myserver")
   670  	f.fe.RequireNoKnownProcess(t, "yourserver")
   671  	f.clock.Advance(time.Second)
   672  	f.triggerButton("b-1", f.clock.Now())
   673  	f.reconcileCmd("testcmd")
   674  
   675  	f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool {
   676  		return cmd.Status.Running != nil
   677  	})
   678  }
   679  
   680  // Ensure that changes to the StartOn or RestartOn fields
   681  // don't restart the command.
   682  func TestDependencyChangesDoNotCauseRestart(t *testing.T) {
   683  	f := newFixture(t)
   684  
   685  	setupStartOnTest(t, f)
   686  	f.triggerButton("b-1", f.clock.Now())
   687  	f.clock.Advance(time.Second)
   688  	f.reconcileCmd("testcmd")
   689  
   690  	firstStart := f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool {
   691  		return cmd.Status.Running != nil
   692  	})
   693  
   694  	err := f.Client.Create(f.Context(), &v1alpha1.UIButton{ObjectMeta: metav1.ObjectMeta{Name: "new-button"}})
   695  	require.NoError(t, err)
   696  
   697  	err = f.Client.Create(f.Context(), &v1alpha1.FileWatch{
   698  		ObjectMeta: metav1.ObjectMeta{Name: "new-filewatch"},
   699  		Spec: FileWatchSpec{
   700  			WatchedPaths: []string{t.TempDir()},
   701  		},
   702  	})
   703  	require.NoError(t, err)
   704  
   705  	f.updateSpec("testcmd", func(spec *v1alpha1.CmdSpec) {
   706  		spec.StartOn = &v1alpha1.StartOnSpec{
   707  			UIButtons: []string{"new-button"},
   708  		}
   709  		spec.RestartOn = &v1alpha1.RestartOnSpec{
   710  			FileWatches: []string{"new-filewatch"},
   711  		}
   712  	})
   713  	f.reconcileCmd("testcmd")
   714  
   715  	f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool {
   716  		running := cmd.Status.Running
   717  		return running != nil && running.StartedAt.Time.Equal(firstStart.Status.Running.StartedAt.Time)
   718  	})
   719  }
   720  
   721  func TestCmdUsesInputsFromButtonOnStart(t *testing.T) {
   722  	f := newFixture(t)
   723  
   724  	setupStartOnTest(t, f)
   725  	f.updateButton("b-1", func(button *v1alpha1.UIButton) {
   726  		button.Spec.Inputs = []v1alpha1.UIInputSpec{
   727  			{Name: "foo", Text: &v1alpha1.UITextInputSpec{}},
   728  			{Name: "baz", Text: &v1alpha1.UITextInputSpec{}},
   729  		}
   730  		button.Status.Inputs = []v1alpha1.UIInputStatus{
   731  			{
   732  				Name: "foo",
   733  				Text: &v1alpha1.UITextInputStatus{Value: "bar"},
   734  			},
   735  			{
   736  				Name: "baz",
   737  				Text: &v1alpha1.UITextInputStatus{Value: "wait what comes next"},
   738  			},
   739  		}
   740  	})
   741  	f.triggerButton("b-1", f.clock.Now())
   742  	f.reconcileCmd("testcmd")
   743  
   744  	actualEnv := f.fe.processes["myserver"].env
   745  	expectedEnv := []string{"foo=bar", "baz=wait what comes next"}
   746  	require.Equal(t, expectedEnv, actualEnv)
   747  }
   748  
   749  func TestBoolInput(t *testing.T) {
   750  	for _, tc := range []struct {
   751  		name          string
   752  		input         v1alpha1.UIBoolInputSpec
   753  		value         bool
   754  		expectedValue string
   755  	}{
   756  		{"true, default", v1alpha1.UIBoolInputSpec{}, true, "true"},
   757  		{"true, custom", v1alpha1.UIBoolInputSpec{TrueString: pointer.String("custom value")}, true, "custom value"},
   758  		{"false, default", v1alpha1.UIBoolInputSpec{}, false, "false"},
   759  		{"false, custom", v1alpha1.UIBoolInputSpec{FalseString: pointer.String("ooh la la")}, false, "ooh la la"},
   760  		{"false, empty", v1alpha1.UIBoolInputSpec{FalseString: pointer.String("")}, false, ""},
   761  	} {
   762  		t.Run(tc.name, func(t *testing.T) {
   763  			f := newFixture(t)
   764  
   765  			setupStartOnTest(t, f)
   766  			f.updateButton("b-1", func(button *v1alpha1.UIButton) {
   767  				spec := v1alpha1.UIInputSpec{Name: "dry_run", Bool: &tc.input}
   768  				button.Spec.Inputs = append(button.Spec.Inputs, spec)
   769  				status := v1alpha1.UIInputStatus{Name: "dry_run", Bool: &v1alpha1.UIBoolInputStatus{Value: tc.value}}
   770  				button.Status.Inputs = append(button.Status.Inputs, status)
   771  			})
   772  			f.triggerButton("b-1", f.clock.Now())
   773  			f.reconcileCmd("testcmd")
   774  
   775  			actualEnv := f.fe.processes["myserver"].env
   776  			expectedEnv := []string{fmt.Sprintf("dry_run=%s", tc.expectedValue)}
   777  			require.Equal(t, expectedEnv, actualEnv)
   778  		})
   779  	}
   780  }
   781  
   782  func TestHiddenInput(t *testing.T) {
   783  	f := newFixture(t)
   784  
   785  	val := "afds"
   786  
   787  	setupStartOnTest(t, f)
   788  	f.updateButton("b-1", func(button *v1alpha1.UIButton) {
   789  		spec := v1alpha1.UIInputSpec{Name: "foo", Hidden: &v1alpha1.UIHiddenInputSpec{Value: val}}
   790  		button.Spec.Inputs = append(button.Spec.Inputs, spec)
   791  		status := v1alpha1.UIInputStatus{Name: "foo", Hidden: &v1alpha1.UIHiddenInputStatus{Value: val}}
   792  		button.Status.Inputs = append(button.Status.Inputs, status)
   793  	})
   794  	f.triggerButton("b-1", f.clock.Now())
   795  	f.reconcileCmd("testcmd")
   796  
   797  	actualEnv := f.fe.processes["myserver"].env
   798  	expectedEnv := []string{fmt.Sprintf("foo=%s", val)}
   799  	require.Equal(t, expectedEnv, actualEnv)
   800  }
   801  
   802  func TestChoiceInput(t *testing.T) {
   803  	for _, tc := range []struct {
   804  		name          string
   805  		input         v1alpha1.UIChoiceInputSpec
   806  		value         string
   807  		expectedValue string
   808  	}{
   809  		{"empty value", v1alpha1.UIChoiceInputSpec{Choices: []string{"choice1", "choice2"}}, "", "choice1"},
   810  		{"invalid value", v1alpha1.UIChoiceInputSpec{Choices: []string{"choice1", "choice2"}}, "not in Choices", "choice1"},
   811  		{"selected choice1", v1alpha1.UIChoiceInputSpec{Choices: []string{"choice1", "choice2"}}, "choice1", "choice1"},
   812  		{"selected choice2", v1alpha1.UIChoiceInputSpec{Choices: []string{"choice1", "choice2"}}, "choice2", "choice2"},
   813  	} {
   814  		t.Run(tc.name, func(t *testing.T) {
   815  			f := newFixture(t)
   816  
   817  			setupStartOnTest(t, f)
   818  			f.updateButton("b-1", func(button *v1alpha1.UIButton) {
   819  				spec := v1alpha1.UIInputSpec{Name: "dry_run", Choice: &tc.input}
   820  				button.Spec.Inputs = append(button.Spec.Inputs, spec)
   821  				status := v1alpha1.UIInputStatus{Name: "dry_run", Choice: &v1alpha1.UIChoiceInputStatus{Value: tc.value}}
   822  				button.Status.Inputs = append(button.Status.Inputs, status)
   823  			})
   824  			f.triggerButton("b-1", f.clock.Now())
   825  			f.reconcileCmd("testcmd")
   826  
   827  			actualEnv := f.fe.processes["myserver"].env
   828  			expectedEnv := []string{fmt.Sprintf("dry_run=%s", tc.expectedValue)}
   829  			require.Equal(t, expectedEnv, actualEnv)
   830  		})
   831  	}
   832  }
   833  
   834  func TestCmdOnlyUsesButtonThatStartedIt(t *testing.T) {
   835  	f := newFixture(t)
   836  
   837  	setupStartOnTest(t, f)
   838  	f.updateButton("b-1", func(button *v1alpha1.UIButton) {
   839  		inputs := []v1alpha1.UIInputStatus{
   840  			{
   841  				Name: "foo",
   842  				Text: &v1alpha1.UITextInputStatus{Value: "bar"},
   843  			},
   844  			{
   845  				Name: "baz",
   846  				Text: &v1alpha1.UITextInputStatus{Value: "wait what comes next"},
   847  			},
   848  		}
   849  		button.Status.Inputs = append(button.Status.Inputs, inputs...)
   850  	})
   851  
   852  	b := &UIButton{
   853  		ObjectMeta: ObjectMeta{
   854  			Name: "b-2",
   855  		},
   856  		Spec: UIButtonSpec{},
   857  	}
   858  	err := f.Client.Create(f.Context(), b)
   859  	require.NoError(t, err)
   860  	f.updateSpec("testcmd", func(spec *v1alpha1.CmdSpec) {
   861  		spec.StartOn.UIButtons = append(spec.StartOn.UIButtons, "b-2")
   862  	})
   863  	f.triggerButton("b-2", f.clock.Now())
   864  	f.reconcileCmd("testcmd")
   865  
   866  	actualEnv := f.fe.processes["myserver"].env
   867  	// b-1's env gets ignored since it was triggered by b-2
   868  	expectedEnv := []string{}
   869  	require.Equal(t, expectedEnv, actualEnv)
   870  }
   871  
   872  type testStore struct {
   873  	*store.TestingStore
   874  	out     io.Writer
   875  	summary store.ChangeSummary
   876  }
   877  
   878  func NewTestingStore(out io.Writer) *testStore {
   879  	return &testStore{
   880  		TestingStore: store.NewTestingStore(),
   881  		out:          out,
   882  	}
   883  }
   884  
   885  func (s *testStore) Cmd(name string) *Cmd {
   886  	st := s.RLockState()
   887  	defer s.RUnlockState()
   888  	return st.Cmds[name]
   889  }
   890  
   891  func (s *testStore) CmdCount() int {
   892  	st := s.RLockState()
   893  	defer s.RUnlockState()
   894  	count := 0
   895  	for _, cmd := range st.Cmds {
   896  		if cmd.DeletionTimestamp == nil {
   897  			count++
   898  		}
   899  	}
   900  	return count
   901  }
   902  
   903  func (s *testStore) Dispatch(action store.Action) {
   904  	s.TestingStore.Dispatch(action)
   905  
   906  	st := s.LockMutableStateForTesting()
   907  	defer s.UnlockMutableState()
   908  
   909  	switch action := action.(type) {
   910  	case store.ErrorAction:
   911  		panic(fmt.Sprintf("no error action allowed: %s", action.Error))
   912  
   913  	case store.LogAction:
   914  		_, _ = s.out.Write(action.Message())
   915  
   916  	case local.CmdCreateAction:
   917  		local.HandleCmdCreateAction(st, action)
   918  		action.Summarize(&s.summary)
   919  
   920  	case local.CmdUpdateStatusAction:
   921  		local.HandleCmdUpdateStatusAction(st, action)
   922  
   923  	case local.CmdDeleteAction:
   924  		local.HandleCmdDeleteAction(st, action)
   925  		action.Summarize(&s.summary)
   926  	}
   927  }
   928  
   929  type fixture struct {
   930  	*fake.ControllerFixture
   931  	st    *testStore
   932  	fe    *FakeExecer
   933  	fpm   *FakeProberManager
   934  	sc    *local.ServerController
   935  	c     *Controller
   936  	clock clockwork.FakeClock
   937  }
   938  
   939  func newFixture(t *testing.T) *fixture {
   940  	f := fake.NewControllerFixtureBuilder(t)
   941  	st := NewTestingStore(f.OutWriter())
   942  
   943  	fe := NewFakeExecer()
   944  	fpm := NewFakeProberManager()
   945  	sc := local.NewServerController(f.Client)
   946  	clock := clockwork.NewFakeClock()
   947  	c := NewController(f.Context(), fe, fpm, f.Client, st, clock, v1alpha1.NewScheme())
   948  
   949  	return &fixture{
   950  		ControllerFixture: f.WithRequeuer(c.requeuer).Build(c),
   951  		st:                st,
   952  		fe:                fe,
   953  		fpm:               fpm,
   954  		sc:                sc,
   955  		c:                 c,
   956  		clock:             clock,
   957  	}
   958  }
   959  
   960  func (f *fixture) triggerFileWatch(name string) {
   961  	fw := &FileWatch{}
   962  	err := f.Client.Get(f.Context(), types.NamespacedName{Name: name}, fw)
   963  	require.NoError(f.T(), err)
   964  
   965  	fw.Status.LastEventTime = apis.NewMicroTime(f.clock.Now())
   966  	err = f.Client.Status().Update(f.Context(), fw)
   967  	require.NoError(f.T(), err)
   968  }
   969  
   970  func (f *fixture) triggerButton(name string, ts time.Time) {
   971  	f.updateButton(name, func(b *v1alpha1.UIButton) {
   972  		b.Status.LastClickedAt = apis.NewMicroTime(ts)
   973  	})
   974  }
   975  
   976  func (f *fixture) reconcileCmd(name string) {
   977  	_, err := f.c.Reconcile(f.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: name}})
   978  	require.NoError(f.T(), err)
   979  }
   980  
   981  func (f *fixture) updateSpec(name string, update func(spec *v1alpha1.CmdSpec)) {
   982  	cmd := &Cmd{}
   983  	err := f.Client.Get(f.Context(), types.NamespacedName{Name: name}, cmd)
   984  	require.NoError(f.T(), err)
   985  
   986  	update(&(cmd.Spec))
   987  	err = f.Client.Update(f.Context(), cmd)
   988  	require.NoError(f.T(), err)
   989  }
   990  
   991  func (f *fixture) updateButton(name string, update func(button *v1alpha1.UIButton)) {
   992  	button := &UIButton{}
   993  	err := f.Client.Get(f.Context(), types.NamespacedName{Name: name}, button)
   994  	require.NoError(f.T(), err)
   995  
   996  	update(button)
   997  
   998  	copy := button.DeepCopy()
   999  	err = f.Client.Update(f.Context(), button)
  1000  	require.NoError(f.T(), err)
  1001  
  1002  	button.Status = copy.Status
  1003  	err = f.Client.Status().Update(f.Context(), button)
  1004  	require.NoError(f.T(), err)
  1005  }
  1006  
  1007  // checks `cmdName`'s DisableSource and makes sure it's configured to be disabled or enabled per `isDisabled`
  1008  func (f *fixture) setDisabled(cmdName string, isDisabled bool) {
  1009  	cmd := &Cmd{}
  1010  	err := f.Client.Get(f.Context(), types.NamespacedName{Name: cmdName}, cmd)
  1011  	require.NoError(f.T(), err)
  1012  
  1013  	require.NotNil(f.T(), cmd.Spec.DisableSource)
  1014  	require.NotNil(f.T(), cmd.Spec.DisableSource.ConfigMap)
  1015  
  1016  	configMap := &ConfigMap{}
  1017  	err = f.Client.Get(f.Context(), types.NamespacedName{Name: cmd.Spec.DisableSource.ConfigMap.Name}, configMap)
  1018  	if apierrors.IsNotFound(err) {
  1019  		configMap.ObjectMeta.Name = cmd.Spec.DisableSource.ConfigMap.Name
  1020  		configMap.Data = map[string]string{cmd.Spec.DisableSource.ConfigMap.Key: strconv.FormatBool(isDisabled)}
  1021  		err = f.Client.Create(f.Context(), configMap)
  1022  		require.NoError(f.T(), err)
  1023  	} else {
  1024  		require.Nil(f.T(), err)
  1025  		configMap.Data[cmd.Spec.DisableSource.ConfigMap.Key] = strconv.FormatBool(isDisabled)
  1026  		err = f.Client.Update(f.Context(), configMap)
  1027  		require.NoError(f.T(), err)
  1028  	}
  1029  
  1030  	f.reconcileCmd(cmdName)
  1031  
  1032  	var expectedDisableState v1alpha1.DisableState
  1033  	if isDisabled {
  1034  		expectedDisableState = v1alpha1.DisableStateDisabled
  1035  	} else {
  1036  		expectedDisableState = v1alpha1.DisableStateEnabled
  1037  	}
  1038  
  1039  	// block until the change has been processed
  1040  	f.requireCmdMatchesInAPI(cmdName, func(cmd *Cmd) bool {
  1041  		return cmd.Status.DisableStatus != nil &&
  1042  			cmd.Status.DisableStatus.State == expectedDisableState
  1043  	})
  1044  }
  1045  
  1046  func (f *fixture) resource(name string, cmd string, workdir string, lastDeploy time.Time) {
  1047  	c := model.ToHostCmd(cmd)
  1048  	c.Dir = workdir
  1049  	localTarget := model.NewLocalTarget(model.TargetName(name), model.Cmd{}, c, nil)
  1050  	f.resourceFromTarget(name, localTarget, lastDeploy)
  1051  }
  1052  
  1053  func (f *fixture) resourceFromTarget(name string, target model.TargetSpec, lastDeploy time.Time) {
  1054  	n := model.ManifestName(name)
  1055  	m := model.Manifest{
  1056  		Name: n,
  1057  	}.WithDeployTarget(target)
  1058  
  1059  	st := f.st.LockMutableStateForTesting()
  1060  	defer f.st.UnlockMutableState()
  1061  
  1062  	state := store.NewManifestState(m)
  1063  	state.LastSuccessfulDeployTime = lastDeploy
  1064  	state.AddCompletedBuild(model.BuildRecord{
  1065  		StartTime:  lastDeploy,
  1066  		FinishTime: lastDeploy,
  1067  	})
  1068  	st.UpsertManifestTarget(&store.ManifestTarget{
  1069  		Manifest: m,
  1070  		State:    state,
  1071  	})
  1072  }
  1073  
  1074  func (f *fixture) step() {
  1075  	f.st.summary = store.ChangeSummary{}
  1076  	_ = f.sc.OnChange(f.Context(), f.st, store.LegacyChangeSummary())
  1077  	for name := range f.st.summary.CmdSpecs.Changes {
  1078  		_, err := f.c.Reconcile(f.Context(), ctrl.Request{NamespacedName: name})
  1079  		require.NoError(f.T(), err)
  1080  	}
  1081  }
  1082  
  1083  func (f *fixture) assertLogMessage(name string, messages ...string) {
  1084  	for _, m := range messages {
  1085  		assert.Eventually(f.T(), func() bool {
  1086  			return strings.Contains(f.Stdout(), m)
  1087  		}, timeout, interval)
  1088  	}
  1089  }
  1090  
  1091  func (f *fixture) waitForLogEventContaining(message string) store.LogAction {
  1092  	ctx, cancel := context.WithTimeout(f.Context(), time.Second)
  1093  	defer cancel()
  1094  
  1095  	for {
  1096  		actions := f.st.Actions()
  1097  		for _, action := range actions {
  1098  			le, ok := action.(store.LogAction)
  1099  			if ok && strings.Contains(string(le.Message()), message) {
  1100  				return le
  1101  			}
  1102  		}
  1103  		select {
  1104  		case <-ctx.Done():
  1105  			f.T().Fatalf("timed out waiting for log event w/ message %q. seen actions: %v", message, actions)
  1106  		case <-time.After(20 * time.Millisecond):
  1107  		}
  1108  	}
  1109  }
  1110  
  1111  func (f *fixture) assertCmdMatches(name string, matcher func(cmd *Cmd) bool) *Cmd {
  1112  	f.T().Helper()
  1113  	assert.Eventually(f.T(), func() bool {
  1114  		cmd := f.st.Cmd(name)
  1115  		if cmd == nil {
  1116  			return false
  1117  		}
  1118  		return matcher(cmd)
  1119  	}, timeout, interval)
  1120  
  1121  	return f.requireCmdMatchesInAPI(name, matcher)
  1122  }
  1123  
  1124  func (f *fixture) requireCmdMatchesInAPI(name string, matcher func(cmd *Cmd) bool) *Cmd {
  1125  	f.T().Helper()
  1126  	var cmd Cmd
  1127  
  1128  	require.Eventually(f.T(), func() bool {
  1129  		err := f.Client.Get(f.Context(), types.NamespacedName{Name: name}, &cmd)
  1130  		require.NoError(f.T(), err)
  1131  		return matcher(&cmd)
  1132  	}, timeout, interval)
  1133  
  1134  	return &cmd
  1135  }
  1136  
  1137  func (f *fixture) assertCmdDeleted(name string) {
  1138  	assert.Eventually(f.T(), func() bool {
  1139  		cmd := f.st.Cmd(name)
  1140  		return cmd == nil || cmd.DeletionTimestamp != nil
  1141  	}, timeout, interval)
  1142  
  1143  	var cmd Cmd
  1144  	err := f.Client.Get(f.Context(), types.NamespacedName{Name: name}, &cmd)
  1145  	assert.Error(f.T(), err)
  1146  	assert.True(f.T(), apierrors.IsNotFound(err))
  1147  }
  1148  
  1149  func (f *fixture) assertCmdCount(count int) {
  1150  	assert.Equal(f.T(), count, f.st.CmdCount())
  1151  
  1152  	var list CmdList
  1153  	err := f.Client.List(f.Context(), &list)
  1154  	require.NoError(f.T(), err)
  1155  	assert.Equal(f.T(), count, len(list.Items))
  1156  }