github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/hud/renderer_test.go (about)

     1  package hud
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"strings"
     7  	"sync"
     8  	"testing"
     9  	"time"
    10  
    11  	"github.com/stretchr/testify/assert"
    12  
    13  	"github.com/tilt-dev/tilt/internal/container"
    14  	"github.com/tilt-dev/tilt/internal/hud/view"
    15  	"github.com/tilt-dev/tilt/internal/rty"
    16  	"github.com/tilt-dev/tilt/internal/store"
    17  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    18  	"github.com/tilt-dev/tilt/pkg/logger"
    19  	"github.com/tilt-dev/tilt/pkg/model"
    20  	"github.com/tilt-dev/tilt/pkg/model/logstore"
    21  
    22  	"github.com/gdamore/tcell"
    23  )
    24  
    25  const testCID = container.ID("beep-boop")
    26  
    27  var clockForTest = func() time.Time { return time.Date(2017, 1, 1, 12, 0, 0, 0, time.UTC) }
    28  
    29  func newView(resources ...view.Resource) view.View {
    30  	return view.View{
    31  		LogReader: newLogReader(""),
    32  		Resources: resources,
    33  	}
    34  }
    35  
    36  func newSpanLogReader(mn model.ManifestName, spanID logstore.SpanID, msg string) logstore.Reader {
    37  	logStore := logstore.NewLogStore()
    38  	logStore.Append(testLogAction{mn: mn, spanID: spanID, time: time.Now(), msg: msg}, nil)
    39  	return logstore.NewReader(&sync.RWMutex{}, logStore)
    40  }
    41  
    42  func newWarningLogReader(mn model.ManifestName, spanID logstore.SpanID, warnings []string) logstore.Reader {
    43  	logStore := logstore.NewLogStore()
    44  	for _, warning := range warnings {
    45  		logStore.Append(testLogAction{
    46  			mn:     mn,
    47  			spanID: spanID,
    48  			time:   time.Now(),
    49  			msg:    warning,
    50  			level:  logger.WarnLvl,
    51  		}, nil)
    52  	}
    53  	return logstore.NewReader(&sync.RWMutex{}, logStore)
    54  }
    55  
    56  func appendSpanLog(logStore *logstore.LogStore, mn model.ManifestName, spanID logstore.SpanID, msg string) {
    57  	logStore.Append(testLogAction{mn: mn, spanID: spanID, time: time.Now(), msg: msg}, nil)
    58  }
    59  
    60  func TestRender(t *testing.T) {
    61  	rtf := newRendererTestFixture(t)
    62  
    63  	v := newView(view.Resource{
    64  		Name:         "foo",
    65  		ResourceInfo: view.K8sResourceInfo{},
    66  	})
    67  
    68  	plainVs := fakeViewState(1, view.CollapseNo)
    69  
    70  	rtf.run("one undeployed resource", 70, 20, v, plainVs)
    71  
    72  	v = newView(view.Resource{
    73  		Name: "a-a-a-aaaaabe vigoda",
    74  		BuildHistory: []model.BuildRecord{{
    75  			FinishTime: time.Now(),
    76  			Error:      fmt.Errorf("oh no the build failed"),
    77  			SpanID:     "vigoda:1",
    78  		}},
    79  		ResourceInfo: view.K8sResourceInfo{},
    80  	})
    81  	v.LogReader = newSpanLogReader("a-a-a-aaaaabe vigoda", "vigoda:1",
    82  		"1\n2\n3\nthe compiler did not understand!\n5\n6\n7\n8\n")
    83  
    84  	rtf.run("inline build log", 70, 20, v, plainVs)
    85  
    86  	v = newView(view.Resource{
    87  		Name: "a-a-a-aaaaabe vigoda",
    88  		BuildHistory: []model.BuildRecord{{
    89  			FinishTime: time.Now(),
    90  			Error:      fmt.Errorf("oh no the build failed"),
    91  			SpanID:     "vigoda:1",
    92  		}},
    93  		ResourceInfo: view.K8sResourceInfo{},
    94  	})
    95  	v.LogReader = newSpanLogReader("a-a-a-aaaaabe vigoda", "vigoda:1",
    96  		`STEP 1/2 — Building Dockerfile: [gcr.io/windmill-public-containers/servantes/snack]
    97    │ Tarring context…
    98    │ Applying via kubectl
    99      ╎ Created tarball (size: 11 kB)
   100    │ Building image
   101      ╎ RUNNING: go install github.com/tilt-dev/servantes/snack
   102  
   103      ╎ ERROR IN: go install github.com/tilt-dev/servantes/snack
   104      ╎   → # github.com/tilt-dev/servantes/snack
   105  src/github.com/tilt-dev/servantes/snack/main.go:16:36: syntax error: unexpected newline, expecting comma or }
   106  
   107  ERROR: ImageBuild: executor failed running [/bin/sh -c go install github.com/tilt-dev/servantes/snack]: exit code 2`)
   108  	rtf.run("inline build log with wrapping", 117, 20, v, plainVs)
   109  
   110  	v = newView(view.Resource{
   111  		Name:      "a-a-a-aaaaabe vigoda",
   112  		Endpoints: []string{"1.2.3.4:8080"},
   113  		ResourceInfo: view.K8sResourceInfo{
   114  			PodName:     "vigoda-pod",
   115  			PodStatus:   "Running",
   116  			PodRestarts: 1,
   117  			SpanID:      "vigoda:pod",
   118  			RunStatus:   v1alpha1.RuntimeStatusOK,
   119  		},
   120  	})
   121  	v.LogReader = newSpanLogReader("a-a-a-aaaaabe vigoda", "vigoda:pod",
   122  		"1\n2\n3\n4\nabe vigoda is now dead\n5\n6\n7\n8\n")
   123  
   124  	rtf.run("pod log displayed inline", 70, 20, v, plainVs)
   125  
   126  	v = newView(view.Resource{
   127  		Name: "a-a-a-aaaaabe vigoda",
   128  		BuildHistory: []model.BuildRecord{{
   129  			Error:  fmt.Errorf("broken go code!"),
   130  			SpanID: "vigoda:1",
   131  		}},
   132  		ResourceInfo: view.K8sResourceInfo{},
   133  	})
   134  	v.LogReader = newSpanLogReader("a-a-a-aaaaabe vigoda", "vigoda:1",
   135  		"mashing keys is not a good way to generate code")
   136  	rtf.run("manifest error and build error", 70, 20, v, plainVs)
   137  
   138  	ts := time.Now().Add(-5 * time.Minute)
   139  	v = newView(view.Resource{
   140  		Name:           "a-a-a-aaaaabe vigoda",
   141  		LastDeployTime: ts,
   142  		BuildHistory: []model.BuildRecord{{
   143  			Edits:      []string{"main.go", "cli.go"},
   144  			Error:      fmt.Errorf("the build failed!"),
   145  			FinishTime: ts,
   146  			StartTime:  ts.Add(-1400 * time.Millisecond),
   147  		}},
   148  		PendingBuildEdits: []string{"main.go", "cli.go", "vigoda.go"},
   149  		PendingBuildSince: ts,
   150  		CurrentBuild: model.BuildRecord{
   151  			Edits:     []string{"main.go"},
   152  			StartTime: ts,
   153  		},
   154  		Endpoints: []string{"1.2.3.4:8080"},
   155  		ResourceInfo: view.K8sResourceInfo{
   156  			PodName:         "vigoda-pod",
   157  			PodCreationTime: ts,
   158  			PodStatus:       "Running",
   159  			RunStatus:       v1alpha1.RuntimeStatusOK,
   160  			PodRestarts:     1,
   161  			SpanID:          "vigoda:pod",
   162  		},
   163  	})
   164  	v.LogReader = newSpanLogReader("a-a-a-aaaaabe vigoda", "vigoda:pod",
   165  		"1\n2\n3\n4\nabe vigoda is now dead\n5\n6\n7\n8\n")
   166  	rtf.run("all the data at once", 70, 20, v, plainVs)
   167  	rtf.run("all the data at once 50w", 50, 20, v, plainVs)
   168  	rtf.run("all the data at once 10w", 10, 20, v, plainVs)
   169  
   170  	v = newView(view.Resource{
   171  		Name:           "vigoda",
   172  		LastDeployTime: ts,
   173  		BuildHistory: []model.BuildRecord{{
   174  			Edits:      []string{"main.go", "cli.go"},
   175  			FinishTime: ts,
   176  			StartTime:  ts.Add(-1400 * time.Millisecond),
   177  		}},
   178  		ResourceInfo: view.K8sResourceInfo{
   179  			PodName:         "vigoda-pod",
   180  			PodCreationTime: ts,
   181  			PodStatus:       "Running",
   182  			RunStatus:       v1alpha1.RuntimeStatusOK,
   183  			PodRestarts:     1,
   184  			SpanID:          "vigoda:pod",
   185  		},
   186  		Endpoints: []string{"1.2.3.4:8080"},
   187  	})
   188  	v.LogReader = newSpanLogReader("vigoda", "vigoda:pod",
   189  		`abe vigoda is crashing
   190  oh noooooooooooooooooo nooooooooooo noooooooooooo nooooooooooo
   191  oh noooooooooooooooooo nooooooooooo noooooooooooo nooooooooooo nooooooooooo noooooooooooo nooooooooooo
   192  oh noooooooooooooooooo nooooooooooo noooooooooooo nooooooooooo
   193  oh noooooooooooooooooo nooooooooooo noooooooooooo nooooooooooo nooooooooooo noooooooooooo nooooooooooo nooooooooooo noooooooooooo nooooooooooo
   194  oh noooooooooooooooooo nooooooooooo noooooooooooo nooooooooooo`)
   195  	rtf.run("pod log with inline wrapping", 70, 20, v, plainVs)
   196  
   197  	v = newView(view.Resource{
   198  		Name: model.UnresourcedYAMLManifestName,
   199  		BuildHistory: []model.BuildRecord{{
   200  			FinishTime: ts,
   201  			StartTime:  ts.Add(-1400 * time.Millisecond),
   202  		}},
   203  		LastDeployTime: ts,
   204  		ResourceInfo: view.YAMLResourceInfo{
   205  			K8sDisplayNames: []string{"sancho:deployment"},
   206  		},
   207  	})
   208  	rtf.run("no collapse unresourced yaml manifest", 70, 20, v, plainVs)
   209  	rtf.run("default collapse unresourced yaml manifest", 70, 20, v, fakeViewState(1, view.CollapseAuto))
   210  
   211  	alertVs := plainVs
   212  	alertVs.AlertMessage = "this is only a test"
   213  	rtf.run("alert message", 70, 20, v, alertVs)
   214  
   215  	v = newView(view.Resource{
   216  		Name: "vigoda",
   217  		CurrentBuild: model.BuildRecord{
   218  			StartTime: ts.Add(-5 * time.Second),
   219  			Edits:     []string{"main.go"},
   220  		},
   221  		ResourceInfo: view.K8sResourceInfo{},
   222  	})
   223  	rtf.run("build in progress", 70, 20, v, plainVs)
   224  
   225  	v = newView(view.Resource{
   226  		Name:              "vigoda",
   227  		PendingBuildSince: ts.Add(-5 * time.Second),
   228  		PendingBuildEdits: []string{"main.go"},
   229  		ResourceInfo: view.K8sResourceInfo{
   230  			RunStatus: v1alpha1.RuntimeStatusPending,
   231  		},
   232  	})
   233  	rtf.run("pending build", 70, 20, v, plainVs)
   234  
   235  	v = newView(view.Resource{
   236  		Name:           "vigoda",
   237  		LastDeployTime: ts.Add(-5 * time.Second),
   238  		BuildHistory: []model.BuildRecord{{
   239  			Edits: []string{"abbot.go", "costello.go", "harold.go"},
   240  		}},
   241  		ResourceInfo: view.K8sResourceInfo{
   242  			RunStatus: v1alpha1.RuntimeStatusPending,
   243  		},
   244  	})
   245  	rtf.run("edited files narrow term", 60, 20, v, plainVs)
   246  	rtf.run("edited files normal term", 80, 20, v, plainVs)
   247  	rtf.run("edited files wide term", 120, 20, v, plainVs)
   248  }
   249  
   250  func TestRenderTiltLog(t *testing.T) {
   251  	rtf := newRendererTestFixture(t)
   252  
   253  	v := newView()
   254  	v.LogReader = newLogReader(strings.Repeat("abcdefg", 30))
   255  
   256  	vs := fakeViewState(0, view.CollapseNo)
   257  
   258  	rtf.run("tilt log", 70, 20, v, vs)
   259  
   260  	vs.TiltLogState = view.TiltLogHalfScreen
   261  	rtf.run("tilt log half screen", 70, 20, v, vs)
   262  
   263  	vs.TiltLogState = view.TiltLogFullScreen
   264  	rtf.run("tilt log full screen", 70, 20, v, vs)
   265  }
   266  
   267  func TestRenderNarrationMessage(t *testing.T) {
   268  	rtf := newRendererTestFixture(t)
   269  
   270  	v := newView()
   271  	vs := view.ViewState{
   272  		ShowNarration:    true,
   273  		NarrationMessage: "hi mom",
   274  	}
   275  
   276  	rtf.run("narration message", 60, 20, v, vs)
   277  }
   278  
   279  func TestAutoCollapseModes(t *testing.T) {
   280  	rtf := newRendererTestFixture(t)
   281  
   282  	goodView := newView(view.Resource{
   283  		Name:         "vigoda",
   284  		ResourceInfo: view.K8sResourceInfo{},
   285  	})
   286  	badView := newView(view.Resource{
   287  		Name: "vigoda",
   288  		BuildHistory: []model.BuildRecord{{
   289  			FinishTime: time.Now(),
   290  			Error:      fmt.Errorf("oh no the build failed"),
   291  			SpanID:     "vigoda:1",
   292  		}},
   293  		ResourceInfo: view.K8sResourceInfo{},
   294  	})
   295  	badView.LogReader = newSpanLogReader("vigoda", "vigoda:1",
   296  		"1\n2\n3\nthe compiler did not understand!\n5\n6\n7\n8\n")
   297  
   298  	autoVS := fakeViewState(1, view.CollapseAuto)
   299  	collapseYesVS := fakeViewState(1, view.CollapseYes)
   300  	collapseNoVS := fakeViewState(1, view.CollapseNo)
   301  	rtf.run("collapse-auto-good", 70, 20, goodView, autoVS)
   302  	rtf.run("collapse-auto-bad", 70, 20, badView, autoVS)
   303  	rtf.run("collapse-no-good", 70, 20, goodView, collapseNoVS)
   304  	rtf.run("collapse-yes-bad", 70, 20, badView, collapseYesVS)
   305  }
   306  
   307  func TestPodPending(t *testing.T) {
   308  	rtf := newRendererTestFixture(t)
   309  	ts := time.Now().Add(-30 * time.Second)
   310  
   311  	v := newView(view.Resource{
   312  		Name: "vigoda",
   313  		BuildHistory: []model.BuildRecord{{
   314  			StartTime:  ts,
   315  			FinishTime: ts,
   316  			SpanID:     "vigoda:1",
   317  		}},
   318  		ResourceInfo: view.K8sResourceInfo{
   319  			PodName:   "vigoda-pod",
   320  			SpanID:    "vigoda:pod",
   321  			PodStatus: "",
   322  		},
   323  		LastDeployTime: ts,
   324  	})
   325  	logStore := logstore.NewLogStore()
   326  	appendSpanLog(logStore, "vigoda", "vigoda:1", `STEP 1/2 — Building Dockerfile: [gcr.io/windmill-public-containers/servantes/snack]
   327    │ Tarring context…
   328    │ Applying via kubectl
   329      ╎ Created tarball (size: 11 kB)
   330    │ Building image
   331  `)
   332  	appendSpanLog(logStore, "vigoda", "vigoda:pod", "serving on 8080")
   333  	v.LogReader = logstore.NewReader(&sync.RWMutex{}, logStore)
   334  	vs := fakeViewState(1, view.CollapseAuto)
   335  
   336  	rtf.run("pending pod no status", 80, 20, v, vs)
   337  	assert.Equal(t, statusDisplay{color: cPending},
   338  		combinedStatus(v.Resources[0]))
   339  
   340  	v.Resources[0].ResourceInfo = view.K8sResourceInfo{
   341  		PodCreationTime: ts,
   342  		PodStatus:       "Pending",
   343  		RunStatus:       v1alpha1.RuntimeStatusPending,
   344  	}
   345  	rtf.run("pending pod pending status", 80, 20, v, vs)
   346  	assert.Equal(t, statusDisplay{color: cPending, spinner: true},
   347  		combinedStatus(v.Resources[0]))
   348  }
   349  
   350  func TestNonCrashingPodNoInlineCrashLog(t *testing.T) {
   351  	rtf := newRendererTestFixture(t)
   352  	ts := time.Now().Add(-30 * time.Second)
   353  
   354  	v := newView(view.Resource{
   355  		Name:      "vigoda",
   356  		Endpoints: []string{"1.2.3.4:8080"},
   357  		BuildHistory: []model.BuildRecord{{
   358  			SpanID:     "vigoda:1",
   359  			StartTime:  ts,
   360  			FinishTime: ts,
   361  		}},
   362  		ResourceInfo: view.K8sResourceInfo{
   363  			PodName:            "vigoda-pod",
   364  			PodStatus:          "Running",
   365  			RunStatus:          v1alpha1.RuntimeStatusOK,
   366  			SpanID:             "vigoda:pod",
   367  			PodUpdateStartTime: ts,
   368  			PodCreationTime:    ts.Add(-time.Minute),
   369  		},
   370  		LastDeployTime: ts,
   371  	})
   372  
   373  	logStore := logstore.NewLogStore()
   374  	appendSpanLog(logStore, "vigoda", "vigoda:1",
   375  		"Building (1/2)\nBuilding (2/2)\n")
   376  	appendSpanLog(logStore, "vigoda", "vigoda:pod",
   377  		"Something's maybe wrong idk")
   378  	v.LogReader = logstore.NewReader(&sync.RWMutex{}, logStore)
   379  
   380  	vs := fakeViewState(1, view.CollapseAuto)
   381  	rtf.run("non-crashing pod displays no logs inline even if crash log if present", 70, 20, v, vs)
   382  }
   383  
   384  func TestCompletedPod(t *testing.T) {
   385  	rtf := newRendererTestFixture(t)
   386  	ts := time.Now().Add(-30 * time.Second)
   387  
   388  	v := newView(view.Resource{
   389  		Name:      "vigoda",
   390  		Endpoints: []string{"1.2.3.4:8080"},
   391  		BuildHistory: []model.BuildRecord{{
   392  			SpanID:     "vigoda:1",
   393  			StartTime:  ts,
   394  			FinishTime: ts,
   395  		}},
   396  		ResourceInfo: view.K8sResourceInfo{
   397  			PodName:            "vigoda-pod",
   398  			PodStatus:          "Completed",
   399  			RunStatus:          v1alpha1.RuntimeStatusOK,
   400  			PodUpdateStartTime: ts,
   401  			PodCreationTime:    ts.Add(-time.Minute),
   402  		},
   403  		LastDeployTime: ts,
   404  	})
   405  	v.LogReader = newSpanLogReader("vigoda", "vigoda:1",
   406  		"Building (1/2)\nBuilding (2/2)\n")
   407  	vs := fakeViewState(1, view.CollapseAuto)
   408  	rtf.run("Completed is a good status", 70, 20, v, vs)
   409  }
   410  
   411  func TestBrackets(t *testing.T) {
   412  	rtf := newRendererTestFixture(t)
   413  	ts := time.Now().Add(-30 * time.Second)
   414  
   415  	v := newView(view.Resource{
   416  		Name: "[vigoda]",
   417  		BuildHistory: []model.BuildRecord{{
   418  			StartTime:  ts,
   419  			FinishTime: ts,
   420  		}},
   421  		ResourceInfo: view.K8sResourceInfo{
   422  			PodName:         "vigoda-pod",
   423  			PodStatus:       "Running",
   424  			RunStatus:       v1alpha1.RuntimeStatusOK,
   425  			PodCreationTime: ts,
   426  		},
   427  		LastDeployTime: ts,
   428  	})
   429  	v.LogReader = newLogReader(`[build] This line should be prefixed with 'build'
   430  [hello world] This line should be prefixed with [hello world]
   431  [hello world] this line too
   432  `)
   433  
   434  	vs := fakeViewState(1, view.CollapseNo)
   435  
   436  	rtf.run("text in brackets", 80, 20, v, vs)
   437  }
   438  
   439  func TestPendingBuildInManualTriggerMode(t *testing.T) {
   440  	rtf := newRendererTestFixture(t)
   441  	ts := time.Now().Add(-30 * time.Second)
   442  	v := newView(view.Resource{
   443  		Name:              "vigoda",
   444  		PendingBuildSince: ts.Add(-5 * time.Second),
   445  		PendingBuildEdits: []string{"main.go"},
   446  		TriggerMode:       model.TriggerModeManualWithAutoInit,
   447  		ResourceInfo:      view.K8sResourceInfo{},
   448  	})
   449  	vs := fakeViewState(1, view.CollapseNo)
   450  	rtf.run("pending build with manual trigger", 80, 20, v, vs)
   451  }
   452  
   453  func TestBuildHistory(t *testing.T) {
   454  	rtf := newRendererTestFixture(t)
   455  	ts := time.Now().Add(-30 * time.Second)
   456  
   457  	v := newView(view.Resource{
   458  		Name: "vigoda",
   459  		BuildHistory: []model.BuildRecord{
   460  			{
   461  				Edits:      []string{"main.go"},
   462  				StartTime:  ts.Add(-10 * time.Second),
   463  				FinishTime: ts,
   464  			},
   465  			{
   466  				Reason:     model.BuildReasonFlagInit,
   467  				StartTime:  ts.Add(-2 * time.Minute),
   468  				FinishTime: ts.Add(-2 * time.Minute).Add(5 * time.Second),
   469  			},
   470  		},
   471  		ResourceInfo: view.K8sResourceInfo{
   472  			PodName:            "vigoda-pod",
   473  			PodStatus:          "Running",
   474  			RunStatus:          v1alpha1.RuntimeStatusOK,
   475  			PodUpdateStartTime: ts,
   476  			PodCreationTime:    ts.Add(-time.Minute),
   477  		},
   478  		LastDeployTime: ts,
   479  	})
   480  	vs := fakeViewState(1, view.CollapseNo)
   481  	rtf.run("multiple build history entries", 80, 20, v, vs)
   482  }
   483  
   484  func TestDockerComposeUpExpanded(t *testing.T) {
   485  	rtf := newRendererTestFixture(t)
   486  
   487  	now := time.Now()
   488  	v := newView(view.Resource{
   489  		Name:         "snack",
   490  		ResourceInfo: view.NewDCResourceInfo("running", testCID, "snack:dc", now.Add(-5*time.Second), v1alpha1.RuntimeStatusOK),
   491  		Endpoints:    []string{"http://localhost:3000"},
   492  		CurrentBuild: model.BuildRecord{
   493  			StartTime: now.Add(-5 * time.Second),
   494  			Reason:    model.BuildReasonFlagChangedFiles,
   495  		},
   496  	})
   497  	v.LogReader = newSpanLogReader("snack", "snack:dc", "hellllo")
   498  
   499  	vs := fakeViewState(1, view.CollapseNo)
   500  	rtf.run("docker-compose up expanded", 80, 20, v, vs)
   501  }
   502  
   503  func TestStatusBarDCRebuild(t *testing.T) {
   504  	rtf := newRendererTestFixture(t)
   505  
   506  	now := time.Now()
   507  	v := newView(view.Resource{
   508  		Name:         "snack",
   509  		ResourceInfo: view.NewDCResourceInfo("exited", testCID, "snack:dc", now.Add(-5*time.Second), v1alpha1.RuntimeStatusError),
   510  		CurrentBuild: model.BuildRecord{
   511  			StartTime: now.Add(-5 * time.Second),
   512  			Reason:    model.BuildReasonFlagChangedFiles,
   513  		},
   514  	})
   515  	v.LogReader = newSpanLogReader("snack", "snack:dc", "hellllo")
   516  
   517  	vs := fakeViewState(1, view.CollapseYes)
   518  	rtf.run("status bar after intentional DC restart", 60, 20, v, vs)
   519  }
   520  
   521  func TestDetectDCCrashExpanded(t *testing.T) {
   522  	rtf := newRendererTestFixture(t)
   523  
   524  	now := time.Now()
   525  	v := newView(view.Resource{
   526  		Name:         "snack",
   527  		ResourceInfo: view.NewDCResourceInfo("exited", testCID, "snack:dc", now.Add(-5*time.Second), v1alpha1.RuntimeStatusError),
   528  	})
   529  	v.LogReader = newSpanLogReader("snack", "snack:dc", "hi im a crash")
   530  
   531  	vs := fakeViewState(1, view.CollapseNo)
   532  	rtf.run("detected docker compose build crash expanded", 80, 20, v, vs)
   533  }
   534  
   535  func TestDetectDCCrashNotExpanded(t *testing.T) {
   536  	rtf := newRendererTestFixture(t)
   537  
   538  	now := time.Now()
   539  	v := newView(view.Resource{
   540  		Name:         "snack",
   541  		ResourceInfo: view.NewDCResourceInfo("exited", testCID, "snack:dc", now.Add(-5*time.Second), v1alpha1.RuntimeStatusError),
   542  	})
   543  	v.LogReader = newSpanLogReader("snack", "snack:dc", "hi im a crash")
   544  
   545  	vs := fakeViewState(1, view.CollapseYes)
   546  	rtf.run("detected docker compose build crash not expanded", 80, 20, v, vs)
   547  }
   548  
   549  func TestDetectDCCrashAutoExpand(t *testing.T) {
   550  	rtf := newRendererTestFixture(t)
   551  
   552  	now := time.Now()
   553  	v := newView(view.Resource{
   554  		Name:         "snack",
   555  		ResourceInfo: view.NewDCResourceInfo("exited", testCID, "snack:dc", now.Add(-5*time.Second), v1alpha1.RuntimeStatusError),
   556  	})
   557  	v.LogReader = newSpanLogReader("snack", "snack:dc", "hi im a crash")
   558  
   559  	vs := fakeViewState(1, view.CollapseAuto)
   560  	rtf.run("detected docker compose build crash auto expand", 80, 20, v, vs)
   561  }
   562  
   563  func TestTiltfileResource(t *testing.T) {
   564  	rtf := newRendererTestFixture(t)
   565  
   566  	v := newView(view.Resource{
   567  		Name:         store.MainTiltfileManifestName,
   568  		IsTiltfile:   true,
   569  		ResourceInfo: view.TiltfileResourceInfo{},
   570  	})
   571  
   572  	vs := fakeViewState(1, view.CollapseNo)
   573  	rtf.run("Tiltfile resource no run", 80, 20, v, vs)
   574  
   575  	now := time.Now()
   576  	v = newView(view.Resource{
   577  		Name:         store.MainTiltfileManifestName,
   578  		IsTiltfile:   true,
   579  		ResourceInfo: view.TiltfileResourceInfo{},
   580  		BuildHistory: []model.BuildRecord{
   581  			{
   582  				StartTime:  now.Add(-5 * time.Second),
   583  				FinishTime: now.Add(-4 * time.Second),
   584  				Reason:     model.BuildReasonFlagInit,
   585  				SpanID:     "tiltfile:1",
   586  			},
   587  		},
   588  	})
   589  	rtf.run("Tiltfile resource first run", 80, 20, v, vs)
   590  }
   591  
   592  func TestTiltfileResourceWithWarning(t *testing.T) {
   593  	rtf := newRendererTestFixture(t)
   594  	now := time.Now()
   595  	v := newView(view.Resource{
   596  		Name:         store.MainTiltfileManifestName,
   597  		IsTiltfile:   true,
   598  		ResourceInfo: view.TiltfileResourceInfo{},
   599  		BuildHistory: []model.BuildRecord{
   600  			{
   601  				Edits:        []string{"Tiltfile"},
   602  				StartTime:    now.Add(-5 * time.Second),
   603  				FinishTime:   now.Add(-4 * time.Second),
   604  				Reason:       model.BuildReasonFlagConfig,
   605  				WarningCount: 2,
   606  				SpanID:       "tiltfile:1",
   607  			},
   608  		},
   609  	})
   610  	v.LogReader = newWarningLogReader(
   611  		store.MainTiltfileManifestName,
   612  		"tiltfile:1",
   613  		[]string{"I am warning you\n", "Something is alarming here\n"})
   614  
   615  	vs := fakeViewState(1, view.CollapseNo)
   616  	rtf.run("Tiltfile resource with warning", 80, 20, v, vs)
   617  }
   618  
   619  func TestTiltfileResourcePending(t *testing.T) {
   620  	rtf := newRendererTestFixture(t)
   621  
   622  	now := time.Now()
   623  	v := newView(view.Resource{
   624  		Name:         store.MainTiltfileManifestName,
   625  		IsTiltfile:   true,
   626  		ResourceInfo: view.TiltfileResourceInfo{},
   627  		CurrentBuild: model.BuildRecord{
   628  			Edits:     []string{"Tiltfile"},
   629  			StartTime: now.Add(-5 * time.Second),
   630  			Reason:    model.BuildReasonFlagConfig,
   631  			SpanID:    "tiltfile:1",
   632  		},
   633  	})
   634  	v.LogReader = newSpanLogReader(store.MainTiltfileManifestName, "tiltfile:1", "Building...")
   635  
   636  	vs := fakeViewState(1, view.CollapseNo)
   637  	rtf.run("Tiltfile resource pending", 80, 20, v, vs)
   638  }
   639  
   640  func TestRenderEscapedNbsp(t *testing.T) {
   641  	rtf := newRendererTestFixture(t)
   642  	plainVs := fakeViewState(1, view.CollapseNo)
   643  	v := newView(view.Resource{
   644  		Name: "vigoda",
   645  		BuildHistory: []model.BuildRecord{{
   646  			FinishTime: time.Now(),
   647  			Error:      fmt.Errorf("oh no the build failed"),
   648  			SpanID:     "vigoda:1",
   649  		}},
   650  		ResourceInfo: view.K8sResourceInfo{},
   651  	})
   652  	v.LogReader = newSpanLogReader("vigoda", "vigoda:1", "\xa0 NBSP!")
   653  	rtf.run("escaped nbsp", 70, 20, v, plainVs)
   654  }
   655  
   656  func TestLineWrappingInInlineError(t *testing.T) {
   657  	rtf := newRendererTestFixture(t)
   658  	vs := fakeViewState(1, view.CollapseNo)
   659  	lines := []string{}
   660  	for i := 0; i < 10; i++ {
   661  		lines = append(lines, fmt.Sprintf("line %d: %s", i, strings.Repeat("xxx ", 20)))
   662  	}
   663  	v := newView(view.Resource{
   664  		Name: "vigoda",
   665  		BuildHistory: []model.BuildRecord{{
   666  			FinishTime: time.Now(),
   667  			Error:      fmt.Errorf("failure"),
   668  			SpanID:     "vigoda:1",
   669  		}},
   670  		ResourceInfo: view.K8sResourceInfo{},
   671  	})
   672  	v.LogReader = newSpanLogReader("vigoda", "vigoda:1", strings.Join(lines, "\n"))
   673  	rtf.run("line wrapping in inline error", 80, 40, v, vs)
   674  }
   675  
   676  func TestRenderTabView(t *testing.T) {
   677  	rtf := newRendererTestFixture(t)
   678  
   679  	vs := fakeViewState(1, view.CollapseAuto)
   680  	now := time.Now()
   681  	v := newView(view.Resource{
   682  		Name: "vigoda",
   683  		BuildHistory: []model.BuildRecord{{
   684  			StartTime:  now.Add(-time.Minute),
   685  			FinishTime: now,
   686  			SpanID:     "vigoda:1",
   687  		}},
   688  		ResourceInfo: view.K8sResourceInfo{
   689  			PodName:         "vigoda-pod",
   690  			PodCreationTime: now,
   691  			PodStatus:       "Running",
   692  			RunStatus:       v1alpha1.RuntimeStatusOK,
   693  			SpanID:          "vigoda:pod",
   694  		},
   695  		LastDeployTime: now,
   696  	})
   697  	logStore := logstore.NewLogStore()
   698  	appendSpanLog(logStore, "vigoda", "vigoda:1",
   699  		`STEP 1/2 — Building Dockerfile: [gcr.io/windmill-public-containers/servantes/snack]
   700    │ Tarring context…
   701    │ Applying via kubectl
   702      ╎ Created tarball (size: 11 kB)
   703    │ Building image
   704  `)
   705  	appendSpanLog(logStore, "vigoda", "vigoda:pod", "serving on 8080")
   706  	v.LogReader = logstore.NewReader(&sync.RWMutex{}, logStore)
   707  
   708  	rtf.run("log tab default", 117, 20, v, vs)
   709  
   710  	vs.TabState = view.TabBuildLog
   711  	rtf.run("log tab build", 117, 20, v, vs)
   712  
   713  	vs.TabState = view.TabRuntimeLog
   714  	rtf.run("log tab pod", 117, 20, v, vs)
   715  }
   716  
   717  func TestPendingLocalResource(t *testing.T) {
   718  	rtf := newRendererTestFixture(t)
   719  
   720  	ts := time.Now().Add(-5 * time.Minute)
   721  
   722  	v := newView(view.Resource{
   723  		Name: "yarn-add",
   724  		CurrentBuild: model.BuildRecord{
   725  			StartTime: ts.Add(-5 * time.Second),
   726  			Edits:     []string{"node.json"},
   727  		},
   728  		ResourceInfo: view.NewLocalResourceInfo(v1alpha1.RuntimeStatusPending, 0, model.LogSpanID("rt1")),
   729  	})
   730  
   731  	vs := fakeViewState(1, view.CollapseAuto)
   732  	rtf.run("unfinished local resource", 80, 20, v, vs)
   733  }
   734  
   735  func TestFinishedLocalResource(t *testing.T) {
   736  	rtf := newRendererTestFixture(t)
   737  
   738  	v := newView(view.Resource{
   739  		Name: "yarn-add",
   740  		BuildHistory: []model.BuildRecord{
   741  			model.BuildRecord{FinishTime: time.Now()},
   742  		},
   743  		ResourceInfo: view.NewLocalResourceInfo(v1alpha1.RuntimeStatusNotApplicable, 0, model.LogSpanID("rt1")),
   744  	})
   745  
   746  	vs := fakeViewState(1, view.CollapseAuto)
   747  	rtf.run("finished local resource", 80, 20, v, vs)
   748  }
   749  
   750  func TestFailedBuildLocalResource(t *testing.T) {
   751  	rtf := newRendererTestFixture(t)
   752  
   753  	v := newView(view.Resource{
   754  		Name: "yarn-add",
   755  		BuildHistory: []model.BuildRecord{
   756  			model.BuildRecord{
   757  				FinishTime: time.Now(),
   758  				Error:      fmt.Errorf("help i'm trapped in an error factory"),
   759  				SpanID:     "build:1",
   760  			},
   761  		},
   762  		ResourceInfo: view.LocalResourceInfo{},
   763  	})
   764  	v.LogReader = newSpanLogReader("yarn-add", "build:1",
   765  		"1\n2\n3\nthe compiler did not understand!\n5\n6\n7\n8\n")
   766  
   767  	vs := fakeViewState(1, view.CollapseAuto)
   768  	rtf.run("failed build local resource", 80, 20, v, vs)
   769  }
   770  
   771  func TestLocalResourceErroredServe(t *testing.T) {
   772  	rtf := newRendererTestFixture(t)
   773  
   774  	v := newView(view.Resource{
   775  		Name: "yarn-add",
   776  		BuildHistory: []model.BuildRecord{
   777  			model.BuildRecord{FinishTime: time.Now()},
   778  		},
   779  		ResourceInfo: view.NewLocalResourceInfo(v1alpha1.RuntimeStatusError, 0, model.LogSpanID("rt1")),
   780  	})
   781  
   782  	vs := fakeViewState(1, view.CollapseAuto)
   783  	rtf.run("local resource errored serve", 80, 20, v, vs)
   784  }
   785  
   786  type rendererTestFixture struct {
   787  	i rty.InteractiveTester
   788  }
   789  
   790  func newRendererTestFixture(t rty.ErrorReporter) rendererTestFixture {
   791  	return rendererTestFixture{
   792  		i: rty.NewInteractiveTester(t, screen),
   793  	}
   794  }
   795  
   796  func (rtf rendererTestFixture) run(name string, w int, h int, v view.View, vs view.ViewState) {
   797  	rtf.i.T().Helper()
   798  
   799  	// Assert that the view is serializable
   800  	serialized, err := json.Marshal(v)
   801  	if err != nil {
   802  		rtf.i.T().Errorf("Malformed view: not serializable: %v\nView: %+q\n", err, v)
   803  	}
   804  
   805  	// Then, assert that the view can be marshaled back.
   806  	if !json.Valid(serialized) {
   807  		rtf.i.T().Errorf("Malformed view: bad serialization: %s", string(serialized))
   808  
   809  	}
   810  
   811  	r := NewRenderer(clockForTest)
   812  	r.rty = rty.NewRTY(tcell.NewSimulationScreen(""), rtf.i.T())
   813  	c := r.layout(v, vs)
   814  	rtf.i.Run(name, w, h, c)
   815  }
   816  
   817  var screen tcell.Screen
   818  
   819  func TestMain(m *testing.M) {
   820  	rty.InitScreenAndRun(m, &screen)
   821  }
   822  
   823  func fakeViewState(count int, collapse view.CollapseState) view.ViewState {
   824  	vs := view.ViewState{}
   825  	for i := 0; i < count; i++ {
   826  		vs.Resources = append(vs.Resources, view.ResourceViewState{
   827  			CollapseState: collapse,
   828  		})
   829  	}
   830  	return vs
   831  }
   832  
   833  func newLogReader(msg string) logstore.Reader {
   834  	store := logstore.NewLogStoreForTesting(msg)
   835  	return logstore.NewReader(&sync.RWMutex{}, store)
   836  }
   837  
   838  type testLogAction struct {
   839  	mn     model.ManifestName
   840  	spanID logstore.SpanID
   841  	time   time.Time
   842  	msg    string
   843  	level  logger.Level
   844  	fields logger.Fields
   845  }
   846  
   847  func (e testLogAction) Fields() logger.Fields {
   848  	return e.fields
   849  }
   850  
   851  func (e testLogAction) Message() []byte {
   852  	return []byte(e.msg)
   853  }
   854  
   855  func (e testLogAction) Level() logger.Level {
   856  	if e.level == (logger.Level{}) {
   857  		return logger.InfoLvl
   858  	}
   859  	return e.level
   860  }
   861  
   862  func (e testLogAction) Time() time.Time {
   863  	return e.time
   864  }
   865  
   866  func (e testLogAction) ManifestName() model.ManifestName {
   867  	return e.mn
   868  }
   869  
   870  func (e testLogAction) SpanID() logstore.SpanID {
   871  	return e.spanID
   872  }