Python · 24289 bytes Raw Blame History
1 #!/usr/bin/env python3
2 """
3 test_shtick_enhanced.py - Comprehensive test suite for shtick commands
4 Enhanced with backup and group management tests
5 """
6
7 import subprocess
8 import tempfile
9 import shutil
10 import os
11 import sys
12 from pathlib import Path
13
14 # ANSI color codes
15 RED = "\033[0;31m"
16 GREEN = "\033[0;32m"
17 YELLOW = "\033[1;33m"
18 NC = "\033[0m" # No Color
19
20
21 class ShtickTester:
22 def __init__(self):
23 self.tests_run = 0
24 self.tests_passed = 0
25 self.tests_failed = 0
26 self.test_dir = None
27 self.original_home = os.environ.get("HOME")
28 self.shtick_cmd = self._find_shtick_command()
29
30 def _find_shtick_command(self):
31 """Find the shtick command to use"""
32 # Method 1: Check if shtick is in PATH
33 if shutil.which("shtick"):
34 print(f"Found shtick in PATH: {shutil.which('shtick')}")
35 return ["shtick"]
36
37 # Method 2: Try running as module from current directory
38 if Path("shtick/cli.py").exists():
39 print("Found shtick module in current directory")
40 return [sys.executable, "-m", "shtick.cli"]
41
42 # Method 3: Try parent directory
43 if Path("../shtick/cli.py").exists():
44 print("Found shtick module in parent directory")
45 os.chdir("..")
46 return [sys.executable, "-m", "shtick.cli"]
47
48 # Method 4: Direct cli.py execution
49 if Path("cli.py").exists():
50 print("Found cli.py in current directory")
51 return [sys.executable, "cli.py"]
52
53 raise RuntimeError(
54 "Cannot find shtick command! Please ensure shtick is installed or run from source directory."
55 )
56
57 def setup(self):
58 """Set up test environment"""
59 self.test_dir = tempfile.mkdtemp(prefix="shtick_test_")
60 os.environ["HOME"] = self.test_dir
61 os.environ["SHTICK_ORIGINAL_HOME"] = (
62 self.original_home
63 ) # For security.py to work
64 os.makedirs(os.path.join(self.test_dir, ".config", "shtick"), exist_ok=True)
65
66 # Create a settings file that disables auto_source_prompt to avoid timeouts
67 settings_path = os.path.join(
68 self.test_dir, ".config", "shtick", "settings.toml"
69 )
70 with open(settings_path, "w") as f:
71 f.write(
72 """# Test settings
73 [behavior]
74 auto_source_prompt = false
75 check_conflicts = true
76 backup_on_save = false
77 interactive_mode = false
78 """
79 )
80
81 print(f"Test directory: {self.test_dir}")
82 print(f"Created settings to disable interactive prompts")
83
84 def cleanup(self):
85 """Clean up test environment"""
86 if self.test_dir and os.path.exists(self.test_dir):
87 shutil.rmtree(self.test_dir)
88 if self.original_home:
89 os.environ["HOME"] = self.original_home
90 else:
91 os.environ.pop("HOME", None)
92 os.environ.pop("SHTICK_ORIGINAL_HOME", None)
93
94 def run_command(self, args, input_text=None, env_override=None):
95 """Run a shtick command and return (output, return_code)"""
96 cmd = self.shtick_cmd + args
97
98 env = os.environ.copy()
99 if env_override:
100 env.update(env_override)
101
102 # Always ensure we're using our test HOME
103 env["HOME"] = self.test_dir
104 env["SHTICK_ORIGINAL_HOME"] = self.original_home # For security.py
105
106 try:
107 # For commands that might prompt, always provide 'n' as input
108 # to avoid hanging on interactive prompts
109 if input_text is None and any(
110 x in args for x in ["alias", "env", "function", "add", "remove"]
111 ):
112 input_text = "n\n"
113
114 result = subprocess.run(
115 cmd,
116 capture_output=True,
117 text=True,
118 input=input_text,
119 env=env,
120 timeout=5, # Reduced timeout since we're handling prompts
121 )
122 return result.stdout + result.stderr, result.returncode
123 except subprocess.TimeoutExpired:
124 return "Command timed out", -1
125 except Exception as e:
126 return f"Error running command: {e}", -1
127
128 def test_command(
129 self,
130 test_name,
131 args,
132 expected_status,
133 description,
134 input_text=None,
135 env_override=None,
136 check_output=None,
137 check_not_output=None,
138 ):
139 """Run a test and check the result"""
140 self.tests_run += 1
141
142 print(f"[{self.tests_run}] {test_name}: {description} ... ", end="", flush=True)
143
144 output, actual_status = self.run_command(args, input_text, env_override)
145
146 # Check status code
147 status_ok = actual_status == expected_status
148
149 # Check output if requested
150 output_ok = True
151 if check_output is not None:
152 output_ok = check_output in output
153 if check_not_output is not None and output_ok:
154 output_ok = check_not_output not in output
155
156 if status_ok and output_ok:
157 print(f"{GREEN}PASS{NC}")
158 self.tests_passed += 1
159 else:
160 print(f"{RED}FAIL{NC}")
161 if not status_ok:
162 print(f" Expected status: {expected_status}, Got: {actual_status}")
163 if not output_ok:
164 if check_output is not None:
165 print(f" Expected output to contain: {check_output}")
166 if check_not_output is not None:
167 print(f" Expected output NOT to contain: {check_not_output}")
168 print(f" Command: {' '.join(self.shtick_cmd + args)}")
169 print(f" Output: {output[:500]}...") # Truncate long output
170 self.tests_failed += 1
171
172 def verify_shtick(self):
173 """Verify shtick command is working"""
174 print("Verifying shtick command...")
175 output, status = self.run_command(["--help"])
176
177 if status != 0:
178 print(f"{RED}ERROR: shtick command not working!{NC}")
179 print(f"Status: {status}")
180 print(f"Output: {output}")
181 sys.exit(1)
182
183 print(f"{GREEN}✓ shtick command verified{NC}\n")
184
185 def run_all_tests(self):
186 """Run all tests"""
187 print("===================================")
188 print("Shtick Command Test Suite (Enhanced)")
189 print("===================================\n")
190
191 self.setup()
192 self.verify_shtick()
193
194 try:
195 # Test 1: Basic commands without config
196 print(f"{YELLOW}Testing basic commands without config:{NC}")
197 self.test_command(
198 "status-no-config", ["status"], 0, "Status should work without config"
199 )
200 self.test_command(
201 "list-no-config", ["list"], 0, "List should work without config"
202 )
203 self.test_command(
204 "shells", ["shells"], 0, "Shells command should always work"
205 )
206
207 # Test 2: Adding items - use proper quoting
208 print(f"\n{YELLOW}Testing add commands:{NC}")
209 self.test_command(
210 "add-alias", ["alias", "ll=ls -la"], 0, "Add persistent alias"
211 )
212 self.test_command(
213 "add-env", ["env", "DEBUG=1"], 0, "Add persistent env var"
214 )
215 self.test_command(
216 "add-function",
217 ["function", "greet=echo Hello"],
218 0,
219 "Add persistent function",
220 )
221
222 # Test 3: Invalid add commands
223 print(f"\n{YELLOW}Testing invalid add commands:{NC}")
224 self.test_command(
225 "add-invalid-key",
226 ["alias", "123bad=value"],
227 1,
228 "Invalid key should fail",
229 )
230 self.test_command(
231 "add-no-equals", ["alias", "no_equals_sign"], 1, "Missing = should fail"
232 )
233 self.test_command(
234 "add-empty-key", ["alias", "=value"], 1, "Empty key should fail"
235 )
236 self.test_command(
237 "add-empty-value", ["alias", "key="], 1, "Empty value should fail"
238 )
239
240 # Test 4: Group operations
241 print(f"\n{YELLOW}Testing group operations:{NC}")
242 self.test_command(
243 "add-to-group",
244 ["add", "alias", "work", "ll=ls -la"],
245 0,
246 "Add to specific group",
247 )
248 self.test_command(
249 "activate-group", ["activate", "work"], 0, "Activate existing group"
250 )
251 self.test_command(
252 "activate-nonexistent",
253 ["activate", "nonexistent"],
254 1,
255 "Activate non-existent group should fail",
256 )
257 self.test_command(
258 "deactivate-group", ["deactivate", "work"], 0, "Deactivate active group"
259 )
260 self.test_command(
261 "deactivate-inactive",
262 ["deactivate", "work"],
263 0,
264 "Deactivate already inactive group (idempotent)",
265 )
266
267 # Test 5: Remove operations - provide input for selection
268 print(f"\n{YELLOW}Testing remove operations:{NC}")
269 self.test_command(
270 "remove-existing",
271 ["remove-persistent", "alias", "ll"],
272 0,
273 "Remove existing alias",
274 input_text="1\n",
275 )
276 self.test_command(
277 "remove-nonexistent",
278 ["remove-persistent", "alias", "nonexistent"],
279 0,
280 "Remove non-existent item (no-op)",
281 )
282
283 # Re-add for next test
284 self.run_command(["add", "alias", "work", "ll=ls -la"])
285 self.test_command(
286 "remove-fuzzy",
287 ["remove", "alias", "work", "ll"],
288 0,
289 "Remove with fuzzy match",
290 input_text="1\n",
291 )
292
293 # Test 6: Generate command
294 print(f"\n{YELLOW}Testing generate command:{NC}")
295 self.test_command(
296 "generate-default",
297 ["generate", "--terse"],
298 0,
299 "Generate with default config",
300 )
301 config_path = os.path.join(
302 self.test_dir, ".config", "shtick", "config.toml"
303 )
304 self.test_command(
305 "generate-custom",
306 ["generate", config_path, "--terse"],
307 0,
308 "Generate with custom config path",
309 )
310 self.test_command(
311 "generate-nonexistent",
312 ["generate", "/tmp/nonexistent.toml"],
313 1,
314 "Generate with non-existent config should fail",
315 )
316
317 # Test 7: Source command
318 print(f"\n{YELLOW}Testing source command:{NC}")
319 self.test_command(
320 "source-bash",
321 ["source"],
322 0,
323 "Source command for bash",
324 env_override={"SHELL": "/bin/bash"},
325 )
326 self.test_command(
327 "source-no-shell",
328 ["source"],
329 1,
330 "Source without shell should fail",
331 env_override={"SHELL": ""},
332 )
333
334 # Test 8: Settings commands
335 print(f"\n{YELLOW}Testing settings commands:{NC}")
336 self.test_command("settings-show", ["settings", "show"], 0, "Show settings")
337 # Don't re-init since we already have settings
338 self.test_command(
339 "settings-set-bool",
340 ["settings", "set", "behavior.backup_on_save", "true"],
341 0,
342 "Set boolean setting",
343 )
344 self.test_command(
345 "settings-set-invalid",
346 ["settings", "set", "invalid.key", "value"],
347 1,
348 "Set invalid setting should fail",
349 )
350
351 # Test 9: Edge cases
352 print(f"\n{YELLOW}Testing edge cases:{NC}")
353 self.test_command(
354 "persistent-activate",
355 ["activate", "persistent"],
356 1,
357 "Cannot activate persistent group",
358 )
359 self.test_command(
360 "persistent-deactivate",
361 ["deactivate", "persistent"],
362 1,
363 "Cannot deactivate persistent group",
364 )
365 long_key = "a" * 65
366 self.test_command(
367 "very-long-key",
368 ["alias", f"{long_key}=value"],
369 1,
370 "Key over 64 chars should fail",
371 )
372
373 # Test 10: Special characters
374 print(f"\n{YELLOW}Testing special characters:{NC}")
375 self.test_command(
376 "alias-with-quotes",
377 ["alias", 'msg=echo "Hello World"'],
378 0,
379 "Alias with quotes",
380 )
381 self.test_command(
382 "alias-with-dollar",
383 ["alias", "home=cd $HOME"],
384 0,
385 "Alias with dollar sign",
386 )
387 self.test_command(
388 "multiline-function",
389 ["function", "hello=echo line1\necho line2"],
390 0,
391 "Multiline function",
392 )
393
394 # Test 11: List and status with content
395 print(f"\n{YELLOW}Testing list and status with content:{NC}")
396 self.test_command("list-with-content", ["list"], 0, "List with items")
397 self.test_command(
398 "list-long-format", ["list", "-l"], 0, "List in long format"
399 )
400 self.test_command(
401 "status-with-content", ["status"], 0, "Status with configuration"
402 )
403
404 # Test 12: Conflict handling
405 print(f"\n{YELLOW}Testing conflict handling:{NC}")
406 # First create an alias in persistent
407 self.run_command(["alias", "mytest=echo test1"])
408 # Then add conflicting alias to different group
409 self.test_command(
410 "add-conflict-alias",
411 ["add", "alias", "temp", "mytest=echo test2"],
412 0,
413 "Add conflicting alias to different group",
414 )
415
416 # Check if warning appears when adding duplicate
417 output, status = self.run_command(["alias", "mytest=echo test3"])
418 if "exists in groups" in output and status == 0:
419 print(
420 f"[{self.tests_run + 1}] check-conflict-warning: Should warn about conflicts ... {GREEN}PASS{NC}"
421 )
422 self.tests_passed += 1
423 else:
424 print(
425 f"[{self.tests_run + 1}] check-conflict-warning: Should warn about conflicts ... {RED}FAIL{NC}"
426 )
427 print(f" Expected warning about existing item, but got: {output}")
428 print(f" Status: {status}")
429 self.tests_failed += 1
430 self.tests_run += 1
431
432 # Test 13: Backup functionality
433 print(f"\n{YELLOW}Testing backup functionality:{NC}")
434 # Add some content first
435 self.run_command(["alias", "backup_test=echo backup"])
436 self.test_command("backup-create", ["backup", "create"], 0, "Create backup")
437 self.test_command(
438 "backup-create-named",
439 ["backup", "create", "-n", "test_backup"],
440 0,
441 "Create named backup",
442 )
443 self.test_command("backup-list", ["backup", "list"], 0, "List backups")
444
445 # Modify config
446 self.run_command(["alias", "after_backup=echo after"])
447
448 # Restore and verify
449 self.test_command(
450 "backup-restore",
451 ["backup", "restore", "test_backup"],
452 0,
453 "Restore from backup",
454 )
455
456 # Verify restore worked by checking if the new alias is gone
457 output, _ = self.run_command(["list"])
458 if "after_backup" not in output:
459 print(
460 f"[{self.tests_run + 1}] verify-restore: Backup restore worked correctly ... {GREEN}PASS{NC}"
461 )
462 self.tests_passed += 1
463 else:
464 print(
465 f"[{self.tests_run + 1}] verify-restore: Backup restore failed ... {RED}FAIL{NC}"
466 )
467 self.tests_failed += 1
468 self.tests_run += 1
469
470 # Test 14: Group management - NOW WITH REAL FUNCTIONALITY!
471 print(f"\n{YELLOW}Testing group management (enhanced):{NC}")
472
473 # Test creating a new group
474 self.test_command(
475 "group-create",
476 ["group", "create", "testgroup"],
477 0,
478 "Create new group",
479 check_output="✓ Created group 'testgroup'",
480 check_not_output="will be created when you add",
481 )
482
483 # Verify the group exists in status
484 output, _ = self.run_command(["status"])
485 if "testgroup: 0 items" in output:
486 print(
487 f"[{self.tests_run + 1}] verify-group-exists: Created group shows in status ... {GREEN}PASS{NC}"
488 )
489 self.tests_passed += 1
490 else:
491 print(
492 f"[{self.tests_run + 1}] verify-group-exists: Created group should show in status ... {RED}FAIL{NC}"
493 )
494 print(f" Status output: {output}")
495 self.tests_failed += 1
496 self.tests_run += 1
497
498 # Verify we can activate the empty group
499 self.test_command(
500 "activate-empty-group",
501 ["activate", "testgroup"],
502 0,
503 "Activate empty group",
504 )
505
506 # Verify the group shows as active
507 output, _ = self.run_command(["status"])
508 if "testgroup: 0 items (ACTIVE)" in output:
509 print(
510 f"[{self.tests_run + 1}] verify-group-active: Empty group shows as active ... {GREEN}PASS{NC}"
511 )
512 self.tests_passed += 1
513 else:
514 print(
515 f"[{self.tests_run + 1}] verify-group-active: Empty group should show as active ... {RED}FAIL{NC}"
516 )
517 print(f" Status output: {output}")
518 self.tests_failed += 1
519 self.tests_run += 1
520
521 # Test adding to the newly created group
522 self.test_command(
523 "add-to-created-group",
524 ["add", "alias", "testgroup", "tg=echo testgroup"],
525 0,
526 "Add item to newly created group",
527 )
528
529 # Verify the group now has 1 item
530 output, _ = self.run_command(["status"])
531 if "testgroup: 1 items (ACTIVE)" in output:
532 print(
533 f"[{self.tests_run + 1}] verify-group-has-item: Group shows 1 item after add ... {GREEN}PASS{NC}"
534 )
535 self.tests_passed += 1
536 else:
537 print(
538 f"[{self.tests_run + 1}] verify-group-has-item: Group should show 1 item ... {RED}FAIL{NC}"
539 )
540 print(f" Status output: {output}")
541 self.tests_failed += 1
542 self.tests_run += 1
543
544 # Test creating duplicate group
545 self.test_command(
546 "group-create-duplicate",
547 ["group", "create", "testgroup"],
548 1,
549 "Cannot create duplicate group",
550 check_output="already exists",
551 )
552
553 # Check the TOML file contains empty sections
554 config_path = os.path.join(
555 self.test_dir, ".config", "shtick", "config.toml"
556 )
557 if os.path.exists(config_path):
558 with open(config_path, "r") as f:
559 toml_content = f.read()
560 # Check for either nested format (with tomli_w) or flat format (fallback)
561 has_nested = (
562 "[testgroup]" in toml_content
563 and "[testgroup.aliases]" in toml_content
564 )
565 has_flat = "[testgroup.aliases]" in toml_content
566 if has_nested or has_flat:
567 print(
568 f"[{self.tests_run + 1}] verify-toml-structure: TOML has proper empty group structure ... {GREEN}PASS{NC}"
569 )
570 self.tests_passed += 1
571 else:
572 print(
573 f"[{self.tests_run + 1}] verify-toml-structure: TOML should have empty group sections ... {RED}FAIL{NC}"
574 )
575 print(f" TOML content:\n{toml_content[:500]}")
576 self.tests_failed += 1
577 self.tests_run += 1
578
579 # Still test unimplemented features
580 self.test_command(
581 "group-rename",
582 ["group", "rename", "testgroup", "newgroup"],
583 1,
584 "Rename group (not implemented)",
585 check_output="not yet implemented",
586 )
587 self.test_command(
588 "group-remove",
589 ["group", "remove", "testgroup", "-f"],
590 1,
591 "Remove group (not implemented)",
592 check_output="not yet implemented",
593 )
594
595 # Test 15: Exit code consistency
596 print(f"\n{YELLOW}Testing exit code consistency:{NC}")
597 # Test that user cancellation uses exit code 2
598 output, status = self.run_command(
599 ["settings", "init"], input_text="\x03"
600 ) # Ctrl+C
601 if status == 2 or "Cancelled" in output:
602 print(
603 f"[{self.tests_run + 1}] user-cancel-exit-code: User cancellation returns code 2 ... {GREEN}PASS{NC}"
604 )
605 self.tests_passed += 1
606 else:
607 print(
608 f"[{self.tests_run + 1}] user-cancel-exit-code: User cancellation should return code 2 ... {RED}FAIL{NC}"
609 )
610 print(f" Got status: {status}, output: {output}")
611 self.tests_failed += 1
612 self.tests_run += 1
613
614 # Test 16: No-output commands should still exit properly
615 print(f"\n{YELLOW}Testing commands with no output still exit properly:{NC}")
616 # Create an empty config scenario
617 empty_dir = tempfile.mkdtemp()
618 self.test_command(
619 "list-empty-explicit-exit",
620 ["list"],
621 0,
622 "List with empty config exits 0",
623 env_override={"HOME": empty_dir},
624 )
625 shutil.rmtree(empty_dir)
626
627 finally:
628 self.cleanup()
629
630 # Summary
631 print("\n===================================")
632 print("Test Summary")
633 print("===================================")
634 print(f"Tests run: {self.tests_run}")
635 print(f"Tests passed: {GREEN}{self.tests_passed}{NC}")
636 print(f"Tests failed: {RED}{self.tests_failed}{NC}")
637
638 if self.tests_failed == 0:
639 print(f"\n{GREEN}All tests passed!{NC}")
640 return 0
641 else:
642 print(f"\n{RED}Some tests failed!{NC}")
643 return 1
644
645
646 def main():
647 """Run the test suite"""
648 # Check if we should run in non-interactive mode
649 if len(sys.argv) > 1 and sys.argv[1] == "--help":
650 print("Usage: test_shtick_enhanced.py")
651 print("Run comprehensive tests for shtick command line tool")
652 return 0
653
654 tester = ShtickTester()
655 return tester.run_all_tests()
656
657
658 if __name__ == "__main__":
659 sys.exit(main())