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 }