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

     1  package rty
     2  
     3  import (
     4  	"encoding/gob"
     5  	"fmt"
     6  	"log"
     7  	"os"
     8  	"path/filepath"
     9  	"regexp"
    10  	"runtime/debug"
    11  	"strings"
    12  	"testing"
    13  
    14  	"github.com/pkg/errors"
    15  	"github.com/rivo/tview"
    16  	"github.com/stretchr/testify/assert"
    17  
    18  	"github.com/gdamore/tcell"
    19  )
    20  
    21  const testDataDir = "testdata"
    22  
    23  // Whitelist characters allowed in a name, because they will be used to create
    24  // filenames.
    25  //
    26  // Forbid filenames with colons because they mess up the Windows git client :(
    27  var validNameRegexp = regexp.MustCompile("^[a-zA-Z0-9 .,_-]+$")
    28  
    29  type InteractiveTester struct {
    30  	usedNames         map[string]bool
    31  	dummyScreen       tcell.SimulationScreen
    32  	interactiveScreen tcell.Screen
    33  	rty               RTY
    34  	t                 ErrorReporter
    35  }
    36  
    37  type ErrorReporter interface {
    38  	Errorf(format string, args ...interface{})
    39  	Fatalf(format string, args ...interface{})
    40  	Helper()
    41  }
    42  
    43  func NewInteractiveTester(t ErrorReporter, screen tcell.Screen) InteractiveTester {
    44  	dummyScreen := tcell.NewSimulationScreen("")
    45  	err := dummyScreen.Init()
    46  	assert.NoError(t, err)
    47  
    48  	return InteractiveTester{
    49  		usedNames:         make(map[string]bool),
    50  		dummyScreen:       dummyScreen,
    51  		interactiveScreen: screen,
    52  		rty:               NewRTY(dummyScreen, t),
    53  		t:                 t,
    54  	}
    55  }
    56  
    57  func (i *InteractiveTester) T() ErrorReporter {
    58  	return i.t
    59  }
    60  
    61  func (i *InteractiveTester) Run(name string, width int, height int, c Component) {
    62  	i.t.Helper()
    63  
    64  	err := i.runCaptureError(name, width, height, c)
    65  	if err != nil {
    66  		i.t.Errorf("error rendering %s: %v", name, err)
    67  	}
    68  	i.dummyScreen.Clear()
    69  }
    70  
    71  func (i *InteractiveTester) render(width int, height int, c Component) Canvas {
    72  	actual := newScreenCanvas(i.dummyScreen, i.t)
    73  	i.dummyScreen.SetSize(width, height)
    74  	defer func() {
    75  		if e := recover(); e != nil {
    76  			i.t.Fatalf("panic rendering: %v %s", e, debug.Stack())
    77  		}
    78  	}()
    79  	i.rty.Render(c)
    80  	return actual
    81  }
    82  
    83  // Returns an error if rendering failed.
    84  // If any other failure is encountered, fails via `i.t`'s `testing.T` and returns `nil`.
    85  func (i *InteractiveTester) runCaptureError(name string, width int, height int, c Component) error {
    86  	i.t.Helper()
    87  	_, ok := i.usedNames[name]
    88  	if ok {
    89  		i.t.Fatalf("test name '%s' was already used", name)
    90  	}
    91  
    92  	if !validNameRegexp.MatchString(name) {
    93  		i.t.Fatalf("test name has invalid characters: %s", name)
    94  	}
    95  
    96  	actual := i.render(width, height, c)
    97  	expected := i.loadGoldenFile(name)
    98  
    99  	eq := canvasesEqual(actual, expected)
   100  	if !eq {
   101  		updated, err := i.displayAndMaybeWrite(name, actual, expected)
   102  		if err == nil {
   103  			if !updated {
   104  				err = errors.New("actual rendering didn't match expected")
   105  			}
   106  		}
   107  		if err != nil {
   108  			i.t.Errorf("%s: %v", name, err)
   109  		}
   110  	}
   111  	return nil
   112  }
   113  
   114  // Default windows terminal fonts typically don't include
   115  // these characters, so we make substitutions.
   116  var equivalentChars = [][]rune{
   117  	{'▼', '↓'},
   118  	{'▶', '→'},
   119  	{'✖', '×'},
   120  	{tview.BoxDrawingsLightDownAndRight, '⠋'},
   121  }
   122  
   123  func canvasesEqual(actual, expected Canvas) bool {
   124  	actualWidth, actualHeight := actual.Size()
   125  	expectedWidth, expectedHeight := expected.Size()
   126  	if actualWidth != expectedWidth || actualHeight != expectedHeight {
   127  		return false
   128  	}
   129  
   130  	for x := 0; x < actualWidth; x++ {
   131  		for y := 0; y < actualHeight; y++ {
   132  			actualCh, _, actualStyle, _ := actual.GetContent(x, y)
   133  			expectedCh, _, expectedStyle, _ := expected.GetContent(x, y)
   134  			isEqualCh := actualCh == expectedCh
   135  			if !isEqualCh {
   136  				for _, pair := range equivalentChars {
   137  					if expectedCh == pair[0] {
   138  						expectedCh = pair[1]
   139  					}
   140  					if actualCh == pair[0] {
   141  						actualCh = pair[1]
   142  
   143  					}
   144  
   145  				}
   146  				isEqualCh = actualCh == expectedCh
   147  			}
   148  			if !isEqualCh || actualStyle != expectedStyle {
   149  				return false
   150  			}
   151  		}
   152  	}
   153  
   154  	return true
   155  }
   156  
   157  func (i *InteractiveTester) renderDiff(screen tcell.Screen, name string, actual, expected Canvas, highlightDiff bool) error {
   158  	screen.Clear()
   159  
   160  	actualWidth, actualHeight := actual.Size()
   161  	expectedWidth, expectedHeight := expected.Size()
   162  
   163  	curHeight := 0
   164  
   165  	printForTest(screen, curHeight, "y: accept, n: reject, d: diff, q: quit")
   166  	curHeight++
   167  
   168  	printForTest(screen, curHeight, fmt.Sprintf("test: %s", name))
   169  	curHeight++
   170  
   171  	printForTest(screen, curHeight, "actual:")
   172  	curHeight++
   173  
   174  	for y := 0; y < actualHeight; y++ {
   175  		for x := 0; x < actualWidth; x++ {
   176  			ch, _, style, _ := actual.GetContent(x, y)
   177  			if highlightDiff {
   178  				expectedCh, _, expectedStyle, _ := expected.GetContent(x, y)
   179  				if ch != expectedCh || style != expectedStyle {
   180  					style = style.Reverse(true)
   181  				}
   182  			}
   183  
   184  			screen.SetContent(x, curHeight, ch, nil, style)
   185  		}
   186  		curHeight++
   187  	}
   188  
   189  	curHeight++
   190  
   191  	printForTest(screen, curHeight, "expected:")
   192  
   193  	curHeight++
   194  
   195  	for y := 0; y < expectedHeight; y++ {
   196  		for x := 0; x < expectedWidth; x++ {
   197  			ch, _, style, _ := expected.GetContent(x, y)
   198  			if highlightDiff {
   199  				actualCh, _, actualStyle, _ := actual.GetContent(x, y)
   200  				if ch != actualCh || style != actualStyle {
   201  					style = style.Reverse(true)
   202  				}
   203  			}
   204  
   205  			screen.SetContent(x, curHeight, ch, nil, style)
   206  		}
   207  		curHeight++
   208  	}
   209  
   210  	screen.Show()
   211  
   212  	return nil
   213  }
   214  
   215  func (i *InteractiveTester) displayAndMaybeWrite(name string, actual, expected Canvas) (updated bool, err error) {
   216  	screen := i.interactiveScreen
   217  	if screen == nil {
   218  		return false, nil
   219  	}
   220  
   221  	highlightDiff := false
   222  
   223  	for {
   224  		err := i.renderDiff(screen, name, actual, expected, highlightDiff)
   225  		if err != nil {
   226  			return false, err
   227  		}
   228  
   229  		ev := screen.PollEvent()
   230  		if ev, ok := ev.(*tcell.EventKey); ok {
   231  			switch ev.Rune() {
   232  			case 'y':
   233  				return true, i.writeGoldenFile(name, actual)
   234  			case 'n':
   235  				return false, errors.New("user indicated expected output was not as desired")
   236  			case 'd':
   237  				highlightDiff = !highlightDiff
   238  			case 'q':
   239  				fmt.Println("User exited by pressing 'q'")
   240  				screen.Fini()
   241  				os.Exit(1)
   242  			}
   243  		}
   244  	}
   245  }
   246  
   247  func printForTest(screen tcell.Screen, y int, text string) {
   248  	for x, ch := range text {
   249  		screen.SetContent(x, y, ch, nil, tcell.StyleDefault)
   250  	}
   251  }
   252  
   253  type caseData struct {
   254  	Width  int
   255  	Height int
   256  	Cells  []caseCell
   257  }
   258  
   259  type caseCell struct {
   260  	Ch    rune
   261  	Style tcell.Style
   262  }
   263  
   264  func (i *InteractiveTester) filename(name string) string {
   265  	return filepath.Join(testDataDir, strings.ReplaceAll(name, "/", "_")+".gob")
   266  }
   267  
   268  func (i *InteractiveTester) loadGoldenFile(name string) Canvas {
   269  	fi, err := os.Open(i.filename(name))
   270  	if err != nil {
   271  		return newTempCanvas(1, 1, tcell.StyleDefault, i.t)
   272  	}
   273  	defer func() {
   274  		err := fi.Close()
   275  		if err != nil {
   276  			log.Printf("error closing file %s\n", fi.Name())
   277  		}
   278  	}()
   279  
   280  	dec := gob.NewDecoder(fi)
   281  	var d caseData
   282  	err = dec.Decode(&d)
   283  	if err != nil {
   284  		return newTempCanvas(1, 1, tcell.StyleDefault, i.t)
   285  	}
   286  
   287  	c := newTempCanvas(d.Width, d.Height, tcell.StyleDefault, i.t)
   288  	for i, cell := range d.Cells {
   289  		x := i % d.Width
   290  		y := i / d.Width
   291  		c.SetContent(x, y, cell.Ch, nil, cell.Style)
   292  	}
   293  
   294  	return c
   295  }
   296  
   297  func (i *InteractiveTester) writeGoldenFile(name string, actual Canvas) error {
   298  	_, err := os.Stat(testDataDir)
   299  	if os.IsNotExist(err) {
   300  		err := os.Mkdir(testDataDir, os.FileMode(0755))
   301  		if err != nil {
   302  			return err
   303  		}
   304  	} else if err != nil {
   305  		return err
   306  	}
   307  	fi, err := os.Create(i.filename(name))
   308  	if err != nil {
   309  		return err
   310  	}
   311  
   312  	width, height := actual.Size()
   313  	d := caseData{
   314  		Width:  width,
   315  		Height: height,
   316  	}
   317  
   318  	// iterative over y first so we write by rows
   319  	for y := 0; y < height; y++ {
   320  		for x := 0; x < width; x++ {
   321  			ch, _, style, _ := actual.GetContent(x, y)
   322  			d.Cells = append(d.Cells, caseCell{Ch: ch, Style: style})
   323  		}
   324  	}
   325  
   326  	enc := gob.NewEncoder(fi)
   327  	return enc.Encode(d)
   328  }
   329  
   330  // unfortunately, tcell misbehaves if we try to make a new Screen for every test
   331  // this function is intended for use from a `TestMain`, so that we can have a global Screen across all tests in the package
   332  func InitScreenAndRun(m *testing.M, screen *tcell.Screen) {
   333  	if s := os.Getenv("RTY_INTERACTIVE"); s != "" {
   334  		var err error
   335  		*screen, err = tcell.NewTerminfoScreen()
   336  		if err != nil {
   337  			log.Fatal(err)
   338  		}
   339  		err = (*screen).Init()
   340  		if err != nil {
   341  			log.Fatal(err)
   342  		}
   343  	}
   344  
   345  	r := m.Run()
   346  	if *screen != nil {
   347  		(*screen).Fini()
   348  	}
   349  
   350  	if r != 0 && *screen == nil {
   351  		log.Printf("To update golden files, run with env variable RTY_INTERACTIVE=1 and hit y/n on each case to overwrite (or not)")
   352  	}
   353  	os.Exit(r)
   354  }