github.com/andrewhsu/cli/v2@v2.0.1-0.20210910131313-d4b4061f5b89/pkg/surveyext/editor_test.go (about)

     1  package surveyext
     2  
     3  import (
     4  	"bytes"
     5  	"errors"
     6  	"fmt"
     7  	"os"
     8  	"strings"
     9  	"sync"
    10  	"testing"
    11  	"time"
    12  
    13  	"github.com/AlecAivazis/survey/v2"
    14  	"github.com/AlecAivazis/survey/v2/terminal"
    15  	pseudotty "github.com/creack/pty"
    16  	"github.com/stretchr/testify/assert"
    17  )
    18  
    19  func testLookPath(s string) ([]string, []string, error) {
    20  	return []string{os.Args[0], "-test.run=TestHelperProcess", "--", s}, []string{"GH_WANT_HELPER_PROCESS=1"}, nil
    21  }
    22  
    23  func TestHelperProcess(t *testing.T) {
    24  	if os.Getenv("GH_WANT_HELPER_PROCESS") != "1" {
    25  		return
    26  	}
    27  	if err := func(args []string) error {
    28  		switch args[0] {
    29  		// "vim" appends a message to the file
    30  		case "vim":
    31  			f, err := os.OpenFile(args[1], os.O_APPEND|os.O_WRONLY, 0)
    32  			if err != nil {
    33  				return err
    34  			}
    35  			defer f.Close()
    36  			_, err = f.WriteString(" - added by vim")
    37  			return err
    38  		// "nano" truncates the contents of the file
    39  		case "nano":
    40  			f, err := os.OpenFile(args[1], os.O_TRUNC|os.O_WRONLY, 0)
    41  			if err != nil {
    42  				return err
    43  			}
    44  			return f.Close()
    45  		default:
    46  			return fmt.Errorf("unrecognized arguments: %#v\n", args)
    47  		}
    48  	}(os.Args[3:]); err != nil {
    49  		fmt.Fprintln(os.Stderr, err)
    50  		os.Exit(1)
    51  	}
    52  	os.Exit(0)
    53  }
    54  
    55  func Test_GhEditor_Prompt_skip(t *testing.T) {
    56  	pty := newTerminal(t)
    57  
    58  	e := &GhEditor{
    59  		BlankAllowed:  true,
    60  		EditorCommand: "vim",
    61  		Editor: &survey.Editor{
    62  			Message:       "Body",
    63  			FileName:      "*.md",
    64  			Default:       "initial value",
    65  			HideDefault:   true,
    66  			AppendDefault: true,
    67  		},
    68  		lookPath: func(s string) ([]string, []string, error) {
    69  			return nil, nil, errors.New("no editor allowed")
    70  		},
    71  	}
    72  	e.WithStdio(pty.Stdio())
    73  
    74  	// wait until the prompt is rendered and send the Enter key
    75  	go func() {
    76  		pty.WaitForOutput("Body")
    77  		assert.Equal(t, "\x1b[0G\x1b[2K\x1b[0;1;92m? \x1b[0m\x1b[0;1;99mBody \x1b[0m\x1b[0;36m[(e) to launch vim, enter to skip] \x1b[0m", normalizeANSI(pty.Output()))
    78  		pty.ResetOutput()
    79  		assert.NoError(t, pty.SendKey('\n'))
    80  	}()
    81  
    82  	res, err := e.Prompt(defaultPromptConfig())
    83  	assert.NoError(t, err)
    84  	assert.Equal(t, "initial value", res)
    85  	assert.Equal(t, "", normalizeANSI(pty.Output()))
    86  }
    87  
    88  func Test_GhEditor_Prompt_editorAppend(t *testing.T) {
    89  	pty := newTerminal(t)
    90  
    91  	e := &GhEditor{
    92  		BlankAllowed:  true,
    93  		EditorCommand: "vim",
    94  		Editor: &survey.Editor{
    95  			Message:       "Body",
    96  			FileName:      "*.md",
    97  			Default:       "initial value",
    98  			HideDefault:   true,
    99  			AppendDefault: true,
   100  		},
   101  		lookPath: testLookPath,
   102  	}
   103  	e.WithStdio(pty.Stdio())
   104  
   105  	// wait until the prompt is rendered and send the 'e' key
   106  	go func() {
   107  		pty.WaitForOutput("Body")
   108  		assert.Equal(t, "\x1b[0G\x1b[2K\x1b[0;1;92m? \x1b[0m\x1b[0;1;99mBody \x1b[0m\x1b[0;36m[(e) to launch vim, enter to skip] \x1b[0m", normalizeANSI(pty.Output()))
   109  		pty.ResetOutput()
   110  		assert.NoError(t, pty.SendKey('e'))
   111  	}()
   112  
   113  	res, err := e.Prompt(defaultPromptConfig())
   114  	assert.NoError(t, err)
   115  	assert.Equal(t, "initial value - added by vim", res)
   116  	assert.Equal(t, "", normalizeANSI(pty.Output()))
   117  }
   118  
   119  func Test_GhEditor_Prompt_editorTruncate(t *testing.T) {
   120  	pty := newTerminal(t)
   121  
   122  	e := &GhEditor{
   123  		BlankAllowed:  true,
   124  		EditorCommand: "nano",
   125  		Editor: &survey.Editor{
   126  			Message:       "Body",
   127  			FileName:      "*.md",
   128  			Default:       "initial value",
   129  			HideDefault:   true,
   130  			AppendDefault: true,
   131  		},
   132  		lookPath: testLookPath,
   133  	}
   134  	e.WithStdio(pty.Stdio())
   135  
   136  	// wait until the prompt is rendered and send the 'e' key
   137  	go func() {
   138  		pty.WaitForOutput("Body")
   139  		assert.Equal(t, "\x1b[0G\x1b[2K\x1b[0;1;92m? \x1b[0m\x1b[0;1;99mBody \x1b[0m\x1b[0;36m[(e) to launch nano, enter to skip] \x1b[0m", normalizeANSI(pty.Output()))
   140  		pty.ResetOutput()
   141  		assert.NoError(t, pty.SendKey('e'))
   142  	}()
   143  
   144  	res, err := e.Prompt(defaultPromptConfig())
   145  	assert.NoError(t, err)
   146  	assert.Equal(t, "", res)
   147  	assert.Equal(t, "", normalizeANSI(pty.Output()))
   148  }
   149  
   150  // survey doesn't expose this
   151  func defaultPromptConfig() *survey.PromptConfig {
   152  	return &survey.PromptConfig{
   153  		PageSize:     7,
   154  		HelpInput:    "?",
   155  		SuggestInput: "tab",
   156  		Icons: survey.IconSet{
   157  			Error: survey.Icon{
   158  				Text:   "X",
   159  				Format: "red",
   160  			},
   161  			Help: survey.Icon{
   162  				Text:   "?",
   163  				Format: "cyan",
   164  			},
   165  			Question: survey.Icon{
   166  				Text:   "?",
   167  				Format: "green+hb",
   168  			},
   169  			MarkedOption: survey.Icon{
   170  				Text:   "[x]",
   171  				Format: "green",
   172  			},
   173  			UnmarkedOption: survey.Icon{
   174  				Text:   "[ ]",
   175  				Format: "default+hb",
   176  			},
   177  			SelectFocus: survey.Icon{
   178  				Text:   ">",
   179  				Format: "cyan+b",
   180  			},
   181  		},
   182  		Filter: func(filter string, value string, index int) (include bool) {
   183  			filter = strings.ToLower(filter)
   184  			return strings.Contains(strings.ToLower(value), filter)
   185  		},
   186  		KeepFilter: false,
   187  	}
   188  }
   189  
   190  type testTerminal struct {
   191  	pty    *os.File
   192  	tty    *os.File
   193  	stdout *teeWriter
   194  	stderr *teeWriter
   195  }
   196  
   197  func newTerminal(t *testing.T) *testTerminal {
   198  	t.Helper()
   199  
   200  	pty, tty, err := pseudotty.Open()
   201  	if errors.Is(err, pseudotty.ErrUnsupported) {
   202  		t.SkipNow()
   203  		return nil
   204  	}
   205  	if err != nil {
   206  		t.Fatal(err)
   207  	}
   208  	t.Cleanup(func() {
   209  		pty.Close()
   210  		tty.Close()
   211  	})
   212  
   213  	if err := pseudotty.Setsize(tty, &pseudotty.Winsize{Cols: 72, Rows: 30}); err != nil {
   214  		t.Fatal(err)
   215  	}
   216  
   217  	return &testTerminal{pty: pty, tty: tty}
   218  }
   219  
   220  func (t *testTerminal) SendKey(c rune) error {
   221  	_, err := t.pty.WriteString(string(c))
   222  	return err
   223  }
   224  
   225  func (t *testTerminal) WaitForOutput(s string) {
   226  	for {
   227  		time.Sleep(time.Millisecond)
   228  		if strings.Contains(t.stdout.String(), s) {
   229  			return
   230  		}
   231  	}
   232  }
   233  
   234  func (t *testTerminal) Output() string {
   235  	return t.stdout.String()
   236  }
   237  
   238  func (t *testTerminal) ResetOutput() {
   239  	t.stdout.Reset()
   240  }
   241  
   242  func (t *testTerminal) Stdio() terminal.Stdio {
   243  	t.stdout = &teeWriter{File: t.tty}
   244  	t.stderr = t.stdout
   245  	return terminal.Stdio{
   246  		In:  t.tty,
   247  		Out: t.stdout,
   248  		Err: t.stderr,
   249  	}
   250  }
   251  
   252  // teeWriter is a writer that duplicates all writes to a file into a buffer
   253  type teeWriter struct {
   254  	*os.File
   255  	buf bytes.Buffer
   256  	mu  sync.Mutex
   257  }
   258  
   259  func (f *teeWriter) Write(p []byte) (n int, err error) {
   260  	f.mu.Lock()
   261  	defer f.mu.Unlock()
   262  	_, _ = f.buf.Write(p)
   263  	return f.File.Write(p)
   264  }
   265  
   266  func (f *teeWriter) String() string {
   267  	f.mu.Lock()
   268  	s := f.buf.String()
   269  	f.mu.Unlock()
   270  	return s
   271  }
   272  
   273  func (f *teeWriter) Reset() {
   274  	f.mu.Lock()
   275  	f.buf.Reset()
   276  	f.mu.Unlock()
   277  }
   278  
   279  // strips some ANSI escape sequences that we do not want tests to be concerned with
   280  func normalizeANSI(t string) string {
   281  	t = strings.ReplaceAll(t, "\x1b[?25h", "") // strip sequence that shows cursor
   282  	t = strings.ReplaceAll(t, "\x1b[?25l", "") // strip sequence that hides cursor
   283  	return t
   284  }