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