github.com/aretext/aretext@v1.3.0/state/shellcmd_test.go (about)

     1  package state
     2  
     3  import (
     4  	"fmt"
     5  	"os"
     6  	"path/filepath"
     7  	"testing"
     8  	"time"
     9  
    10  	"github.com/stretchr/testify/assert"
    11  	"github.com/stretchr/testify/require"
    12  
    13  	"github.com/aretext/aretext/config"
    14  	"github.com/aretext/aretext/selection"
    15  )
    16  
    17  func runShellCmdAndApplyAction(t *testing.T, state *EditorState, cmd string, mode string) {
    18  	RunShellCmd(state, cmd, mode)
    19  	if mode == config.CmdModeTerminal {
    20  		return // executes synchronously
    21  	}
    22  
    23  	// Wait for asynchronous task to complete and apply resulting action.
    24  	select {
    25  	case action := <-state.TaskResultChan():
    26  		action(state)
    27  
    28  	case <-time.After(5 * time.Second):
    29  		require.Fail(t, "Timed out")
    30  	}
    31  }
    32  
    33  func TestRunShellCmd(t *testing.T) {
    34  	setupShellCmdTest(t, func(state *EditorState, dir string) {
    35  		p := filepath.Join(dir, "test-output.txt")
    36  		cmd := fmt.Sprintf(`printf "hello" > %s`, p)
    37  		runShellCmdAndApplyAction(t, state, cmd, config.CmdModeSilent)
    38  		data, err := os.ReadFile(p)
    39  		require.NoError(t, err)
    40  		assert.Equal(t, "hello", string(data))
    41  	})
    42  }
    43  
    44  func TestRunShellCmdFilePathEnvVar(t *testing.T) {
    45  	setupShellCmdTest(t, func(state *EditorState, dir string) {
    46  		filePath := filepath.Join(dir, "test-input.txt")
    47  		os.WriteFile(filePath, []byte("xyz"), 0644)
    48  		LoadDocument(state, filePath, true, func(LocatorParams) uint64 { return 0 })
    49  
    50  		p := filepath.Join(dir, "test-output.txt")
    51  		cmd := fmt.Sprintf(`printenv FILEPATH > %s`, p)
    52  		runShellCmdAndApplyAction(t, state, cmd, config.CmdModeSilent)
    53  		data, err := os.ReadFile(p)
    54  		require.NoError(t, err)
    55  		assert.Equal(t, filePath+"\n", string(data))
    56  	})
    57  }
    58  
    59  func TestRunShellCmdWordEnvVar(t *testing.T) {
    60  	testCases := []struct {
    61  		name               string
    62  		text               string
    63  		cursorPos          uint64
    64  		expectedWordEnvVar string
    65  	}{
    66  		{
    67  			name:               "empty document",
    68  			text:               "",
    69  			cursorPos:          0,
    70  			expectedWordEnvVar: "",
    71  		},
    72  		{
    73  			name:               "non-empty word",
    74  			text:               "abcd  xyz  123",
    75  			cursorPos:          7,
    76  			expectedWordEnvVar: "xyz",
    77  		},
    78  		{
    79  			name:               "whitespace between words",
    80  			text:               "abcd  xyz  123",
    81  			cursorPos:          4,
    82  			expectedWordEnvVar: "",
    83  		},
    84  	}
    85  
    86  	for _, tc := range testCases {
    87  		t.Run(tc.name, func(t *testing.T) {
    88  			setupShellCmdTest(t, func(state *EditorState, dir string) {
    89  				for _, r := range tc.text {
    90  					InsertRune(state, r)
    91  				}
    92  				MoveCursor(state, func(p LocatorParams) uint64 { return tc.cursorPos })
    93  
    94  				p := filepath.Join(dir, "test-output.txt")
    95  				cmd := fmt.Sprintf(`printenv WORD > %s`, p)
    96  				runShellCmdAndApplyAction(t, state, cmd, config.CmdModeSilent)
    97  				data, err := os.ReadFile(p)
    98  				require.NoError(t, err)
    99  				assert.Equal(t, tc.expectedWordEnvVar+"\n", string(data))
   100  			})
   101  		})
   102  	}
   103  }
   104  
   105  func TestRunShellCmdLineAndColumnEnvVars(t *testing.T) {
   106  	testCases := []struct {
   107  		name                 string
   108  		text                 string
   109  		cursorPos            uint64
   110  		expectedLineEnvVar   string
   111  		expectedColumnEnvVar string
   112  	}{
   113  		{
   114  			name:                 "empty document",
   115  			text:                 "",
   116  			cursorPos:            0,
   117  			expectedLineEnvVar:   "1",
   118  			expectedColumnEnvVar: "1",
   119  		},
   120  		{
   121  			name:                 "single line",
   122  			text:                 "abc",
   123  			cursorPos:            0,
   124  			expectedLineEnvVar:   "1",
   125  			expectedColumnEnvVar: "1",
   126  		},
   127  		{
   128  			name:                 "multiple lines, cursor on first line",
   129  			text:                 "abc\ndef\nghi",
   130  			cursorPos:            2,
   131  			expectedLineEnvVar:   "1",
   132  			expectedColumnEnvVar: "3",
   133  		},
   134  		{
   135  			name:                 "multiple lines, cursor on second line",
   136  			text:                 "abc\ndef\nghi",
   137  			cursorPos:            4,
   138  			expectedLineEnvVar:   "2",
   139  			expectedColumnEnvVar: "1",
   140  		},
   141  		{
   142  			name:                 "multiple lines, cursor on last line",
   143  			text:                 "abc\ndef\nghi",
   144  			cursorPos:            10,
   145  			expectedLineEnvVar:   "3",
   146  			expectedColumnEnvVar: "3",
   147  		},
   148  		{
   149  			name:                 "line with multi-byte unicode",
   150  			text:                 "\U0010AAAA abcd",
   151  			cursorPos:            1,
   152  			expectedLineEnvVar:   "1",
   153  			expectedColumnEnvVar: "5", // column counts bytes, not runes.
   154  		},
   155  	}
   156  
   157  	for _, tc := range testCases {
   158  		t.Run(tc.name, func(t *testing.T) {
   159  			setupShellCmdTest(t, func(state *EditorState, dir string) {
   160  				for _, r := range tc.text {
   161  					InsertRune(state, r)
   162  				}
   163  				MoveCursor(state, func(p LocatorParams) uint64 { return tc.cursorPos })
   164  
   165  				p := filepath.Join(dir, "test-output.txt")
   166  				cmd := fmt.Sprintf(`printenv LINE > %s; printenv COLUMN >> %s`, p, p)
   167  				runShellCmdAndApplyAction(t, state, cmd, config.CmdModeSilent)
   168  				data, err := os.ReadFile(p)
   169  				require.NoError(t, err)
   170  				expected := fmt.Sprintf("%s\n%s\n", tc.expectedLineEnvVar, tc.expectedColumnEnvVar)
   171  				assert.Equal(t, expected, string(data))
   172  			})
   173  		})
   174  	}
   175  }
   176  
   177  func TestRunShellCmdWithSelection(t *testing.T) {
   178  	setupShellCmdTest(t, func(state *EditorState, dir string) {
   179  		for _, r := range "foobar" {
   180  			InsertRune(state, r)
   181  		}
   182  		ToggleVisualMode(state, selection.ModeLine)
   183  
   184  		p := filepath.Join(dir, "test-output.txt")
   185  		cmd := fmt.Sprintf(`printenv SELECTION > %s`, p)
   186  		runShellCmdAndApplyAction(t, state, cmd, config.CmdModeSilent)
   187  		data, err := os.ReadFile(p)
   188  		require.NoError(t, err)
   189  		assert.Equal(t, "foobar\n", string(data))
   190  	})
   191  }
   192  
   193  func TestRunShellCmdInsertIntoDocument(t *testing.T) {
   194  	testCases := []struct {
   195  		name              string
   196  		documentText      string
   197  		insertedText      string
   198  		cursorPos         uint64
   199  		expectedCursorPos uint64
   200  		expectedText      string
   201  	}{
   202  		{
   203  			name:              "insert into empty document",
   204  			documentText:      "",
   205  			insertedText:      "hello world",
   206  			cursorPos:         0,
   207  			expectedCursorPos: 10,
   208  			expectedText:      "hello world",
   209  		},
   210  		{
   211  			name:              "insert into document with text",
   212  			documentText:      "foo bar",
   213  			insertedText:      "hello world",
   214  			cursorPos:         3,
   215  			expectedCursorPos: 14,
   216  			expectedText:      "foo hello worldbar",
   217  		},
   218  	}
   219  
   220  	for _, tc := range testCases {
   221  		t.Run(tc.name, func(t *testing.T) {
   222  			setupShellCmdTest(t, func(state *EditorState, dir string) {
   223  				// Setup initial state.
   224  				for _, r := range tc.documentText {
   225  					InsertRune(state, r)
   226  				}
   227  				MoveCursor(state, func(p LocatorParams) uint64 { return tc.cursorPos })
   228  
   229  				// Create test file with content
   230  				p := filepath.Join(dir, "test-output.txt")
   231  				err := os.WriteFile(p, []byte(tc.insertedText), 0644)
   232  				require.NoError(t, err)
   233  
   234  				// Execute command to insert contents of text file.
   235  				cmd := fmt.Sprintf("cat %s", p)
   236  				runShellCmdAndApplyAction(t, state, cmd, config.CmdModeInsert)
   237  
   238  				// Check the document state.
   239  				s := state.documentBuffer.textTree.String()
   240  				cursorPos := state.documentBuffer.cursor.position
   241  				assert.Equal(t, tc.expectedText, s)
   242  				assert.Equal(t, tc.expectedCursorPos, cursorPos)
   243  				assert.Equal(t, InputModeNormal, state.InputMode())
   244  			})
   245  		})
   246  	}
   247  }
   248  
   249  func TestRunShellCmdInsertIntoDocumentThenUndo(t *testing.T) {
   250  	setupShellCmdTest(t, func(state *EditorState, dir string) {
   251  		// Setup initial state.
   252  		for _, r := range "abcd" {
   253  			InsertRune(state, r)
   254  		}
   255  		MoveCursor(state, func(p LocatorParams) uint64 { return 2 })
   256  
   257  		// Create test file with content
   258  		p := filepath.Join(dir, "test-output.txt")
   259  		err := os.WriteFile(p, []byte("xyz"), 0644)
   260  		require.NoError(t, err)
   261  
   262  		// Execute command to insert contents of text file.
   263  		cmd := fmt.Sprintf("cat %s", p)
   264  		runShellCmdAndApplyAction(t, state, cmd, config.CmdModeInsert)
   265  
   266  		// Undo the last action.
   267  		Undo(state)
   268  
   269  		// Check the document state.
   270  		s := state.documentBuffer.textTree.String()
   271  		cursorPos := state.documentBuffer.cursor.position
   272  		assert.Equal(t, "abcd", s)
   273  		assert.Equal(t, uint64(2), cursorPos)
   274  		assert.Equal(t, InputModeNormal, state.InputMode())
   275  	})
   276  }
   277  
   278  func TestRunShellCmdInsertIntoDocumentWithSelection(t *testing.T) {
   279  	testCases := []struct {
   280  		name              string
   281  		documentText      string
   282  		insertedText      string
   283  		selectionMode     selection.Mode
   284  		cursorStartPos    uint64
   285  		cursorEndPos      uint64
   286  		expectedCursorPos uint64
   287  		expectedText      string
   288  	}{
   289  		{
   290  			name:              "charwise selection",
   291  			documentText:      "foobar",
   292  			insertedText:      "hello world",
   293  			selectionMode:     selection.ModeChar,
   294  			cursorStartPos:    3,
   295  			cursorEndPos:      4,
   296  			expectedCursorPos: 13,
   297  			expectedText:      "foohello worldr",
   298  		},
   299  		{
   300  			name:              "linewise selection",
   301  			documentText:      "foo\nbar\nbaz\nbat",
   302  			insertedText:      "hello world",
   303  			selectionMode:     selection.ModeLine,
   304  			cursorStartPos:    5,
   305  			cursorEndPos:      9,
   306  			expectedCursorPos: 14,
   307  			expectedText:      "foo\nhello world\nbat",
   308  		},
   309  	}
   310  
   311  	for _, tc := range testCases {
   312  		t.Run(tc.name, func(t *testing.T) {
   313  			setupShellCmdTest(t, func(state *EditorState, dir string) {
   314  				for _, r := range tc.documentText {
   315  					InsertRune(state, r)
   316  				}
   317  				MoveCursor(state, func(p LocatorParams) uint64 { return tc.cursorStartPos })
   318  				ToggleVisualMode(state, tc.selectionMode)
   319  				MoveCursor(state, func(p LocatorParams) uint64 { return tc.cursorEndPos })
   320  
   321  				p := filepath.Join(dir, "test-output.txt")
   322  				err := os.WriteFile(p, []byte(tc.insertedText), 0644)
   323  				require.NoError(t, err)
   324  				cmd := fmt.Sprintf("cat %s", p)
   325  				runShellCmdAndApplyAction(t, state, cmd, config.CmdModeInsert)
   326  				s := state.documentBuffer.textTree.String()
   327  				cursorPos := state.documentBuffer.cursor.position
   328  				assert.Equal(t, tc.expectedText, s)
   329  				assert.Equal(t, tc.expectedCursorPos, cursorPos)
   330  				assert.Equal(t, InputModeNormal, state.InputMode())
   331  			})
   332  		})
   333  	}
   334  }
   335  
   336  func TestRunShellCmdInsertChoiceMenu(t *testing.T) {
   337  	setupShellCmdTest(t, func(state *EditorState, dir string) {
   338  		// Run a command that outputs two lines.
   339  		cmd := "printf 'abc\nxyz'"
   340  		runShellCmdAndApplyAction(t, state, cmd, config.CmdModeInsertChoice)
   341  
   342  		// Verify that the insert choice menu loads with the two lines.
   343  		assert.Equal(t, InputModeMenu, state.InputMode())
   344  		menuItems, _ := state.Menu().SearchResults()
   345  		require.Equal(t, 2, len(menuItems))
   346  		assert.Equal(t, "abc", menuItems[0].Name)
   347  		assert.Equal(t, "xyz", menuItems[1].Name)
   348  
   349  		// Execute the first menu item and verify the text is inserted.
   350  		ExecuteSelectedMenuItem(state)
   351  		s := state.documentBuffer.textTree.String()
   352  		cursorPos := state.documentBuffer.cursor.position
   353  		assert.Equal(t, "abc", s)
   354  		assert.Equal(t, uint64(2), cursorPos)
   355  		assert.Equal(t, InputModeNormal, state.InputMode())
   356  	})
   357  }
   358  
   359  func TestRunShellCmdFileLocationsMenu(t *testing.T) {
   360  	setupShellCmdTest(t, func(state *EditorState, dir string) {
   361  		// Create a test file to load.
   362  		p := filepath.Join(dir, "test-file.txt")
   363  		err := os.WriteFile(p, []byte("ab\ncd\nef\ngh"), 0644)
   364  		require.NoError(t, err)
   365  
   366  		// Populate the location list with a single file location.
   367  		cmd := fmt.Sprintf("echo '%s:2:cd'", p)
   368  		runShellCmdAndApplyAction(t, state, cmd, config.CmdModeFileLocations)
   369  
   370  		// Verify that the location list menu opens.
   371  		assert.Equal(t, InputModeMenu, state.InputMode())
   372  		menuItems, _ := state.Menu().SearchResults()
   373  		require.Equal(t, 1, len(menuItems))
   374  		expectedName := fmt.Sprintf("%s:2  cd", p)
   375  		assert.Equal(t, expectedName, menuItems[0].Name)
   376  
   377  		// Execute the menu item and verify that the document loads.
   378  		ExecuteSelectedMenuItem(state)
   379  		assert.Equal(t, p, state.fileWatcher.Path())
   380  		assert.Equal(t, uint64(3), state.documentBuffer.cursor.position)
   381  		text := state.documentBuffer.textTree.String()
   382  		assert.Equal(t, "ab\ncd\nef\ngh", text)
   383  	})
   384  }
   385  
   386  func TestRunShellCmdWorkingDirMenu(t *testing.T) {
   387  	setupShellCmdTest(t, func(state *EditorState, dir string) {
   388  		// Save the original working dir so we can restore it later.
   389  		originalWorkingDir, err := os.Getwd()
   390  		require.NoError(t, err)
   391  		defer os.Chdir(originalWorkingDir)
   392  
   393  		// Populate the menu with a path to a temp dir.
   394  		dirPath := t.TempDir()
   395  		dirPath, err = filepath.EvalSymlinks(dirPath)
   396  		require.NoError(t, err)
   397  		cmd := fmt.Sprintf("echo '%s'", dirPath)
   398  		runShellCmdAndApplyAction(t, state, cmd, config.CmdModeWorkingDir)
   399  
   400  		// Verify that the menu shows the path.
   401  		assert.Equal(t, InputModeMenu, state.InputMode())
   402  		menuItems, _ := state.Menu().SearchResults()
   403  		require.Equal(t, 1, len(menuItems))
   404  		assert.Equal(t, dirPath, menuItems[0].Name)
   405  
   406  		// Execute the menu item and verify that the working directory changes.
   407  		ExecuteSelectedMenuItem(state)
   408  		workingDir, err := os.Getwd()
   409  		require.NoError(t, err)
   410  		assert.Equal(t, dirPath, workingDir)
   411  	})
   412  }
   413  
   414  func setupShellCmdTest(t *testing.T, f func(*EditorState, string)) {
   415  	oldShellEnv := os.Getenv("SHELL")
   416  	defer os.Setenv("SHELL", oldShellEnv)
   417  	os.Setenv("SHELL", "")
   418  
   419  	oldAretextShellEnv := os.Getenv("ARETEXT_SHELL")
   420  	defer os.Setenv("ARETEXT_SHELL", oldAretextShellEnv)
   421  	os.Setenv("ARETEXT_SHELL", "")
   422  
   423  	suspendScreenFunc := func(f func() error) error { return f() }
   424  	state := NewEditorState(100, 100, nil, suspendScreenFunc)
   425  	defer state.fileWatcher.Stop()
   426  
   427  	dir := t.TempDir()
   428  
   429  	f(state, dir)
   430  }