Skip to content

Commit ac9156e

Browse files
committed
Add automated tests for shell scripts
- Add tests for user_prompt_submit.sh hook - Add tests for capture_prompt_work_tree.sh helper - Add tests for make_new_job.sh utility - Add JSON format validation tests enforcing Claude Code hooks response format - Tests verify that hook scripts return valid JSON with correct structure
1 parent 1b2cb53 commit ac9156e

5 files changed

Lines changed: 1461 additions & 1 deletion

File tree

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
"""Tests for capture_prompt_work_tree.sh helper script.
2+
3+
This script captures the git work tree state for use with
4+
compare_to: prompt policies. It should:
5+
1. Create .deepwork directory if needed
6+
2. Stage all changes with git add -A
7+
3. Record changed files to .deepwork/.last_work_tree
8+
4. Handle various git states gracefully
9+
"""
10+
11+
import os
12+
import subprocess
13+
from pathlib import Path
14+
15+
import pytest
16+
from git import Repo
17+
18+
19+
@pytest.fixture
20+
def shell_scripts_dir() -> Path:
21+
"""Return the path to the source shell scripts directory."""
22+
return (
23+
Path(__file__).parent.parent.parent
24+
/ "src"
25+
/ "deepwork"
26+
/ "standard_jobs"
27+
/ "deepwork_policy"
28+
/ "hooks"
29+
)
30+
31+
32+
@pytest.fixture
33+
def git_repo(tmp_path: Path) -> Path:
34+
"""Create a basic git repo for testing."""
35+
repo = Repo.init(tmp_path)
36+
37+
readme = tmp_path / "README.md"
38+
readme.write_text("# Test Project\n")
39+
repo.index.add(["README.md"])
40+
repo.index.commit("Initial commit")
41+
42+
return tmp_path
43+
44+
45+
@pytest.fixture
46+
def git_repo_with_changes(git_repo: Path) -> Path:
47+
"""Create a git repo with uncommitted changes."""
48+
# Create some changed files
49+
(git_repo / "modified.py").write_text("# Modified file\n")
50+
(git_repo / "src").mkdir(exist_ok=True)
51+
(git_repo / "src" / "main.py").write_text("# Main file\n")
52+
53+
return git_repo
54+
55+
56+
def run_capture_script(
57+
script_path: Path,
58+
cwd: Path,
59+
) -> tuple[str, str, int]:
60+
"""
61+
Run the capture_prompt_work_tree.sh script.
62+
63+
Args:
64+
script_path: Path to the capture_prompt_work_tree.sh script
65+
cwd: Working directory to run the script in
66+
67+
Returns:
68+
Tuple of (stdout, stderr, return_code)
69+
"""
70+
env = os.environ.copy()
71+
72+
result = subprocess.run(
73+
["bash", str(script_path)],
74+
cwd=cwd,
75+
capture_output=True,
76+
text=True,
77+
env=env,
78+
)
79+
80+
return result.stdout, result.stderr, result.returncode
81+
82+
83+
class TestCapturePromptWorkTreeBasic:
84+
"""Basic functionality tests for capture_prompt_work_tree.sh."""
85+
86+
def test_exits_successfully(
87+
self, shell_scripts_dir: Path, git_repo: Path
88+
) -> None:
89+
"""Test that the script exits with code 0."""
90+
script_path = shell_scripts_dir / "capture_prompt_work_tree.sh"
91+
stdout, stderr, code = run_capture_script(script_path, git_repo)
92+
93+
assert code == 0, f"Expected exit code 0, got {code}. stderr: {stderr}"
94+
95+
def test_creates_deepwork_directory(
96+
self, shell_scripts_dir: Path, git_repo: Path
97+
) -> None:
98+
"""Test that the script creates .deepwork directory."""
99+
deepwork_dir = git_repo / ".deepwork"
100+
assert not deepwork_dir.exists(), "Precondition: .deepwork should not exist"
101+
102+
script_path = shell_scripts_dir / "capture_prompt_work_tree.sh"
103+
stdout, stderr, code = run_capture_script(script_path, git_repo)
104+
105+
assert code == 0, f"Script failed with stderr: {stderr}"
106+
assert deepwork_dir.exists(), "Script should create .deepwork directory"
107+
108+
def test_creates_last_work_tree_file(
109+
self, shell_scripts_dir: Path, git_repo: Path
110+
) -> None:
111+
"""Test that the script creates .last_work_tree file."""
112+
script_path = shell_scripts_dir / "capture_prompt_work_tree.sh"
113+
stdout, stderr, code = run_capture_script(script_path, git_repo)
114+
115+
work_tree_file = git_repo / ".deepwork" / ".last_work_tree"
116+
assert code == 0, f"Script failed with stderr: {stderr}"
117+
assert work_tree_file.exists(), "Script should create .last_work_tree file"
118+
119+
def test_empty_repo_produces_empty_file(
120+
self, shell_scripts_dir: Path, git_repo: Path
121+
) -> None:
122+
"""Test that a clean repo produces an empty work tree file."""
123+
script_path = shell_scripts_dir / "capture_prompt_work_tree.sh"
124+
stdout, stderr, code = run_capture_script(script_path, git_repo)
125+
126+
# Clean repo should have empty or minimal content
127+
# May have .deepwork/.last_work_tree itself listed
128+
assert code == 0, f"Script failed with stderr: {stderr}"
129+
130+
131+
class TestCapturePromptWorkTreeFileTracking:
132+
"""Tests for file tracking behavior in capture_prompt_work_tree.sh."""
133+
134+
def test_captures_staged_files(
135+
self, shell_scripts_dir: Path, git_repo: Path
136+
) -> None:
137+
"""Test that staged files are captured."""
138+
# Create and stage a file
139+
new_file = git_repo / "staged.py"
140+
new_file.write_text("# Staged file\n")
141+
repo = Repo(git_repo)
142+
repo.index.add(["staged.py"])
143+
144+
script_path = shell_scripts_dir / "capture_prompt_work_tree.sh"
145+
stdout, stderr, code = run_capture_script(script_path, git_repo)
146+
147+
work_tree_file = git_repo / ".deepwork" / ".last_work_tree"
148+
content = work_tree_file.read_text()
149+
150+
assert code == 0, f"Script failed with stderr: {stderr}"
151+
assert "staged.py" in content, "Staged file should be in work tree"
152+
153+
def test_captures_unstaged_changes(
154+
self, shell_scripts_dir: Path, git_repo: Path
155+
) -> None:
156+
"""Test that unstaged changes are captured (after staging by script)."""
157+
# Create an unstaged file
158+
unstaged = git_repo / "unstaged.py"
159+
unstaged.write_text("# Unstaged file\n")
160+
161+
script_path = shell_scripts_dir / "capture_prompt_work_tree.sh"
162+
stdout, stderr, code = run_capture_script(script_path, git_repo)
163+
164+
work_tree_file = git_repo / ".deepwork" / ".last_work_tree"
165+
content = work_tree_file.read_text()
166+
167+
assert code == 0, f"Script failed with stderr: {stderr}"
168+
assert "unstaged.py" in content, "Unstaged file should be captured"
169+
170+
def test_captures_files_in_subdirectories(
171+
self, shell_scripts_dir: Path, git_repo: Path
172+
) -> None:
173+
"""Test that files in subdirectories are captured."""
174+
# Create files in nested directories
175+
src_dir = git_repo / "src" / "components"
176+
src_dir.mkdir(parents=True)
177+
(src_dir / "button.py").write_text("# Button component\n")
178+
179+
script_path = shell_scripts_dir / "capture_prompt_work_tree.sh"
180+
stdout, stderr, code = run_capture_script(script_path, git_repo)
181+
182+
work_tree_file = git_repo / ".deepwork" / ".last_work_tree"
183+
content = work_tree_file.read_text()
184+
185+
assert code == 0, f"Script failed with stderr: {stderr}"
186+
assert "src/components/button.py" in content, "Nested file should be captured"
187+
188+
def test_captures_multiple_files(
189+
self, shell_scripts_dir: Path, git_repo_with_changes: Path
190+
) -> None:
191+
"""Test that multiple files are captured."""
192+
script_path = shell_scripts_dir / "capture_prompt_work_tree.sh"
193+
stdout, stderr, code = run_capture_script(script_path, git_repo_with_changes)
194+
195+
work_tree_file = git_repo_with_changes / ".deepwork" / ".last_work_tree"
196+
content = work_tree_file.read_text()
197+
198+
assert code == 0, f"Script failed with stderr: {stderr}"
199+
assert "modified.py" in content, "Modified file should be captured"
200+
assert "src/main.py" in content, "File in src/ should be captured"
201+
202+
def test_file_list_is_sorted_and_unique(
203+
self, shell_scripts_dir: Path, git_repo: Path
204+
) -> None:
205+
"""Test that the file list is sorted and deduplicated."""
206+
# Create multiple files
207+
(git_repo / "z_file.py").write_text("# Z file\n")
208+
(git_repo / "a_file.py").write_text("# A file\n")
209+
(git_repo / "m_file.py").write_text("# M file\n")
210+
211+
script_path = shell_scripts_dir / "capture_prompt_work_tree.sh"
212+
stdout, stderr, code = run_capture_script(script_path, git_repo)
213+
214+
work_tree_file = git_repo / ".deepwork" / ".last_work_tree"
215+
lines = [line for line in work_tree_file.read_text().strip().split("\n") if line]
216+
217+
# Extract just the test files we created (filter out .deepwork files)
218+
test_files = [f for f in lines if f.endswith("_file.py")]
219+
220+
assert code == 0, f"Script failed with stderr: {stderr}"
221+
assert test_files == sorted(test_files), "Files should be sorted"
222+
assert len(test_files) == len(set(test_files)), "Files should be unique"
223+
224+
225+
class TestCapturePromptWorkTreeGitStates:
226+
"""Tests for handling various git states in capture_prompt_work_tree.sh."""
227+
228+
def test_handles_deleted_files(
229+
self, shell_scripts_dir: Path, git_repo: Path
230+
) -> None:
231+
"""Test that deleted files are handled gracefully."""
232+
# Create and commit a file, then delete it
233+
to_delete = git_repo / "to_delete.py"
234+
to_delete.write_text("# Will be deleted\n")
235+
repo = Repo(git_repo)
236+
repo.index.add(["to_delete.py"])
237+
repo.index.commit("Add file to delete")
238+
239+
# Now delete it
240+
to_delete.unlink()
241+
242+
script_path = shell_scripts_dir / "capture_prompt_work_tree.sh"
243+
stdout, stderr, code = run_capture_script(script_path, git_repo)
244+
245+
assert code == 0, f"Script should handle deletions. stderr: {stderr}"
246+
247+
def test_handles_renamed_files(
248+
self, shell_scripts_dir: Path, git_repo: Path
249+
) -> None:
250+
"""Test that renamed files are tracked."""
251+
# Create and commit a file
252+
old_name = git_repo / "old_name.py"
253+
old_name.write_text("# Original file\n")
254+
repo = Repo(git_repo)
255+
repo.index.add(["old_name.py"])
256+
repo.index.commit("Add original file")
257+
258+
# Rename it
259+
new_name = git_repo / "new_name.py"
260+
old_name.rename(new_name)
261+
262+
script_path = shell_scripts_dir / "capture_prompt_work_tree.sh"
263+
stdout, stderr, code = run_capture_script(script_path, git_repo)
264+
265+
work_tree_file = git_repo / ".deepwork" / ".last_work_tree"
266+
content = work_tree_file.read_text()
267+
268+
assert code == 0, f"Script failed with stderr: {stderr}"
269+
# Both old (deleted) and new should appear as changes
270+
assert "new_name.py" in content, "New filename should be captured"
271+
272+
def test_handles_modified_files(
273+
self, shell_scripts_dir: Path, git_repo: Path
274+
) -> None:
275+
"""Test that modified committed files are tracked."""
276+
# Modify an existing committed file
277+
readme = git_repo / "README.md"
278+
readme.write_text("# Modified content\n")
279+
280+
script_path = shell_scripts_dir / "capture_prompt_work_tree.sh"
281+
stdout, stderr, code = run_capture_script(script_path, git_repo)
282+
283+
work_tree_file = git_repo / ".deepwork" / ".last_work_tree"
284+
content = work_tree_file.read_text()
285+
286+
assert code == 0, f"Script failed with stderr: {stderr}"
287+
assert "README.md" in content, "Modified file should be captured"
288+
289+
290+
class TestCapturePromptWorkTreeIdempotence:
291+
"""Tests for idempotent behavior of capture_prompt_work_tree.sh."""
292+
293+
def test_multiple_runs_succeed(
294+
self, shell_scripts_dir: Path, git_repo: Path
295+
) -> None:
296+
"""Test that the script can be run multiple times."""
297+
script_path = shell_scripts_dir / "capture_prompt_work_tree.sh"
298+
299+
for i in range(3):
300+
stdout, stderr, code = run_capture_script(script_path, git_repo)
301+
assert code == 0, f"Run {i + 1} failed with stderr: {stderr}"
302+
303+
def test_updates_on_new_changes(
304+
self, shell_scripts_dir: Path, git_repo: Path
305+
) -> None:
306+
"""Test that subsequent runs capture new changes."""
307+
script_path = shell_scripts_dir / "capture_prompt_work_tree.sh"
308+
309+
# First run
310+
run_capture_script(script_path, git_repo)
311+
312+
# Add a new file
313+
(git_repo / "new_file.py").write_text("# New\n")
314+
315+
# Second run
316+
run_capture_script(script_path, git_repo)
317+
318+
work_tree_file = git_repo / ".deepwork" / ".last_work_tree"
319+
content = work_tree_file.read_text()
320+
321+
assert "new_file.py" in content, "New file should be captured"
322+
323+
def test_existing_deepwork_dir_not_error(
324+
self, shell_scripts_dir: Path, git_repo: Path
325+
) -> None:
326+
"""Test that existing .deepwork directory is not an error."""
327+
# Pre-create the directory
328+
(git_repo / ".deepwork").mkdir()
329+
330+
script_path = shell_scripts_dir / "capture_prompt_work_tree.sh"
331+
stdout, stderr, code = run_capture_script(script_path, git_repo)
332+
333+
assert code == 0, f"Should handle existing .deepwork dir. stderr: {stderr}"

0 commit comments

Comments
 (0)