Skip to content

Commit c95f3e5

Browse files
nhortonclaude
andauthored
Add automated tests for all scripts (#40)
* 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 * Refactor shell script tests based on code review - Add shared fixtures to conftest.py (git_repo, git_repo_with_policy, etc.) - Add shared run_shell_script helper function - Remove duplicate fixture definitions from test files - Add assertion to test_rejects_name_with_spaces - Remove redundant TestCapturePromptWorkTreeJsonFormat class - Apply ruff formatting to all test files * Add README for shell script tests --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 846e738 commit c95f3e5

8 files changed

Lines changed: 1325 additions & 92 deletions

File tree

tests/shell_script_tests/README.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Shell Script Tests
2+
3+
Automated tests for DeepWork shell scripts, with a focus on validating Claude Code hooks JSON response formats.
4+
5+
## Scripts Tested
6+
7+
| Script | Type | Description |
8+
|--------|------|-------------|
9+
| `policy_stop_hook.sh` | Stop Hook | Evaluates policies and blocks agent stop if policies are triggered |
10+
| `user_prompt_submit.sh` | UserPromptSubmit Hook | Captures work tree state when user submits a prompt |
11+
| `capture_prompt_work_tree.sh` | Helper | Records current git state for `compare_to: prompt` policies |
12+
| `make_new_job.sh` | Utility | Creates directory structure for new DeepWork jobs |
13+
14+
## Claude Code Hooks JSON Format
15+
16+
Hook scripts must return valid JSON responses. The tests enforce these formats:
17+
18+
### Stop Hooks (`hooks.after_agent`)
19+
```json
20+
{} // Allow stop
21+
{"decision": "block", "reason": "..."} // Block stop with reason
22+
```
23+
24+
### UserPromptSubmit Hooks (`hooks.before_prompt`)
25+
```json
26+
{} // No output or empty object (side-effect only hooks)
27+
```
28+
29+
### All Hooks
30+
- Must return valid JSON if producing output
31+
- Non-JSON output on stdout is **not allowed** (stderr is ok)
32+
- Exit code 0 indicates success (even when blocking)
33+
34+
## Running Tests
35+
36+
```bash
37+
# Run all shell script tests
38+
uv run pytest tests/shell_script_tests/ -v
39+
40+
# Run tests for a specific script
41+
uv run pytest tests/shell_script_tests/test_policy_stop_hook.py -v
42+
43+
# Run with coverage
44+
uv run pytest tests/shell_script_tests/ --cov=src/deepwork
45+
```
46+
47+
## Test Structure
48+
49+
```
50+
tests/shell_script_tests/
51+
├── conftest.py # Shared fixtures and helpers
52+
├── test_policy_stop_hook.py # Stop hook blocking/allowing tests
53+
├── test_user_prompt_submit.py # Prompt submission hook tests
54+
├── test_capture_prompt_work_tree.py # Work tree capture tests
55+
├── test_hooks_json_format.py # JSON format validation tests
56+
└── test_make_new_job.py # Job directory creation tests
57+
```
58+
59+
## Shared Fixtures
60+
61+
Available in `conftest.py`:
62+
63+
| Fixture | Description |
64+
|---------|-------------|
65+
| `git_repo` | Basic git repo with initial commit |
66+
| `git_repo_with_policy` | Git repo with a Python file policy |
67+
| `policy_hooks_dir` | Path to policy hooks scripts |
68+
| `jobs_scripts_dir` | Path to job management scripts |
69+
70+
## Adding New Tests
71+
72+
1. Use shared fixtures from `conftest.py` when possible
73+
2. Use `run_shell_script()` helper for running scripts
74+
3. Validate JSON output with `validate_json_output()` and `validate_stop_hook_response()`
75+
4. Test both success and failure cases
76+
5. Verify exit codes (hooks should exit 0 even when blocking)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Shared fixtures for shell script tests."""
2+
3+
import json
4+
import os
5+
import subprocess
6+
from pathlib import Path
7+
8+
import pytest
9+
from git import Repo
10+
11+
12+
@pytest.fixture
13+
def git_repo(tmp_path: Path) -> Path:
14+
"""Create a basic git repo for testing."""
15+
repo = Repo.init(tmp_path)
16+
17+
readme = tmp_path / "README.md"
18+
readme.write_text("# Test Project\n")
19+
repo.index.add(["README.md"])
20+
repo.index.commit("Initial commit")
21+
22+
return tmp_path
23+
24+
25+
@pytest.fixture
26+
def git_repo_with_policy(tmp_path: Path) -> Path:
27+
"""Create a git repo with policy that will fire."""
28+
repo = Repo.init(tmp_path)
29+
30+
readme = tmp_path / "README.md"
31+
readme.write_text("# Test Project\n")
32+
repo.index.add(["README.md"])
33+
repo.index.commit("Initial commit")
34+
35+
# Policy that triggers on any Python file
36+
policy_file = tmp_path / ".deepwork.policy.yml"
37+
policy_file.write_text(
38+
"""- name: "Python File Policy"
39+
trigger: "**/*.py"
40+
compare_to: prompt
41+
instructions: |
42+
Review Python files for quality.
43+
"""
44+
)
45+
46+
# Empty baseline so new files trigger
47+
deepwork_dir = tmp_path / ".deepwork"
48+
deepwork_dir.mkdir(exist_ok=True)
49+
(deepwork_dir / ".last_work_tree").write_text("")
50+
51+
return tmp_path
52+
53+
54+
@pytest.fixture
55+
def policy_hooks_dir() -> Path:
56+
"""Return the path to the policy hooks scripts directory."""
57+
return (
58+
Path(__file__).parent.parent.parent
59+
/ "src"
60+
/ "deepwork"
61+
/ "standard_jobs"
62+
/ "deepwork_policy"
63+
/ "hooks"
64+
)
65+
66+
67+
@pytest.fixture
68+
def jobs_scripts_dir() -> Path:
69+
"""Return the path to the jobs scripts directory."""
70+
return (
71+
Path(__file__).parent.parent.parent / "src" / "deepwork" / "standard_jobs" / "deepwork_jobs"
72+
)
73+
74+
75+
def run_shell_script(
76+
script_path: Path,
77+
cwd: Path,
78+
args: list[str] | None = None,
79+
hook_input: dict | None = None,
80+
env_extra: dict[str, str] | None = None,
81+
) -> tuple[str, str, int]:
82+
"""
83+
Run a shell script and return its output.
84+
85+
Args:
86+
script_path: Path to the shell script
87+
cwd: Working directory to run the script in
88+
args: Optional list of arguments to pass to the script
89+
hook_input: Optional JSON input to pass via stdin
90+
env_extra: Optional extra environment variables
91+
92+
Returns:
93+
Tuple of (stdout, stderr, return_code)
94+
"""
95+
env = os.environ.copy()
96+
env["PYTHONPATH"] = str(Path(__file__).parent.parent.parent / "src")
97+
if env_extra:
98+
env.update(env_extra)
99+
100+
cmd = ["bash", str(script_path)]
101+
if args:
102+
cmd.extend(args)
103+
104+
stdin_data = json.dumps(hook_input) if hook_input else ""
105+
106+
result = subprocess.run(
107+
cmd,
108+
cwd=cwd,
109+
capture_output=True,
110+
text=True,
111+
input=stdin_data,
112+
env=env,
113+
)
114+
115+
return result.stdout, result.stderr, result.returncode

0 commit comments

Comments
 (0)