@objctp/opencode-shell-routines 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/agents/shell-architect.md +88 -0
- package/agents/shell-expert.md +60 -0
- package/commands/shell-audit.md +47 -0
- package/commands/shell-batch-exec.md +48 -0
- package/commands/shell-new.md +57 -0
- package/commands/shell-routines-setup.md +66 -0
- package/commands/shell-test-run.md +46 -0
- package/opencode.json +19 -0
- package/package.json +34 -0
- package/plugins/shell-hooks.ts +150 -0
- package/scripts/lib-batch.sh +297 -0
- package/scripts/lib-common.sh +332 -0
- package/skills/shell-batch-operations/SKILL.md +97 -0
- package/skills/shell-batch-operations/assets/batch-template.sh +124 -0
- package/skills/shell-batch-operations/examples/data-pipeline.sh +157 -0
- package/skills/shell-batch-operations/examples/file-batch.sh +140 -0
- package/skills/shell-batch-operations/references/decision-tree.md +53 -0
- package/skills/shell-best-practices/SKILL.md +313 -0
- package/skills/shell-best-practices/assets/library.sh +142 -0
- package/skills/shell-best-practices/assets/minimal.sh +54 -0
- package/skills/shell-best-practices/assets/posix.sh +180 -0
- package/skills/shell-best-practices/assets/standard.sh +203 -0
- package/skills/shell-best-practices/references/patterns.md +386 -0
- package/skills/shell-best-practices/references/security.md +195 -0
- package/skills/shell-debugging/SKILL.md +115 -0
- package/skills/shell-debugging/examples/debug-session.md +165 -0
- package/skills/shell-debugging/references/debugging-guide.md +336 -0
- package/skills/shell-profiling/SKILL.md +154 -0
- package/skills/shell-profiling/examples/profile-session.md +225 -0
- package/skills/shell-profiling/references/optimisation-patterns.md +373 -0
- package/skills/shell-profiling/references/profiling-tools.md +318 -0
- package/skills/shell-profiling/scripts/bench.sh +82 -0
- package/skills/shell-profiling/scripts/trace-aggregate.sh +34 -0
- package/skills/shell-review/SKILL.md +61 -0
- package/skills/shell-review/examples/sample-review.md +42 -0
- package/skills/shell-review/references/guidelines.md +48 -0
- package/skills/shell-review/references/review-template.md +56 -0
- package/skills/shell-security/SKILL.md +128 -0
- package/skills/shell-security/examples/dangerous-command-review.md +231 -0
- package/skills/shell-security/examples/secure-script-example.sh +317 -0
- package/skills/shell-security/references/dangerous-commands.md +561 -0
- package/skills/shell-security/references/security-patterns.md +30 -0
- package/skills/shell-security/references/sensitive-files.md +525 -0
- package/skills/shell-security/scripts/security-audit.sh +208 -0
- package/skills/shell-test/SKILL.md +237 -0
- package/skills/shell-test/examples/test-example.md +74 -0
- package/skills/shell-test/references/advanced-patterns.md +52 -0
- package/skills/shell-test/references/assertions.md +184 -0
- package/skills/shell-test/references/test-template.md +60 -0
- package/skills/shell-test/scripts/public-coverage.sh +93 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# security-audit.sh -- Scan shell scripts for security vulnerabilities
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# ./security-audit.sh <file|directory>
|
|
6
|
+
#
|
|
7
|
+
# Scans .sh files for destructive commands, hardcoded credentials,
|
|
8
|
+
# insecure permissions, fork bombs, and other security risks.
|
|
9
|
+
# For detailed explanations and fix commands, see references/dangerous-commands.md
|
|
10
|
+
#
|
|
11
|
+
# shellcheck disable=SC2329
|
|
12
|
+
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
###
|
|
16
|
+
### :::: Helpers :::: #################
|
|
17
|
+
###
|
|
18
|
+
|
|
19
|
+
readonly RED='\033[0;31m'
|
|
20
|
+
readonly PUR='\033[0;35m'
|
|
21
|
+
readonly YEL='\033[0;33m'
|
|
22
|
+
readonly LGRN='\033[92m'
|
|
23
|
+
readonly RST='\033[0m'
|
|
24
|
+
readonly BOLD='\033[1m'
|
|
25
|
+
|
|
26
|
+
function fatal() { echo -e "${PUR}${BOLD}◆ [FATAL]${RST} $*"; }
|
|
27
|
+
function severe() { echo -e "${RED}● [SEVERE]${RST} $*"; }
|
|
28
|
+
function moderate() { echo -e "${YEL}▲ [MODERATE]${RST} $*"; }
|
|
29
|
+
function clean() { echo -e "${LGRN}✔ [OK]${RST} $*"; }
|
|
30
|
+
|
|
31
|
+
###
|
|
32
|
+
### :::: Check functions :::: #########
|
|
33
|
+
###
|
|
34
|
+
|
|
35
|
+
# Internal: grep a file for a pattern, report matches via severity function
|
|
36
|
+
# Usage: _check_grep FILE PATTERN SEVERITY_FUNC OK_MSG [MSG_PREFIX]
|
|
37
|
+
# SEVERITY_FUNC: fatal, severe, or moderate
|
|
38
|
+
# OK_MSG: message shown when no matches found
|
|
39
|
+
# MSG_PREFIX: optional prefix before the match (e.g. "Possible fork bomb: ")
|
|
40
|
+
# Returns: 0 if clean, 1 if issues found
|
|
41
|
+
function _check_grep() {
|
|
42
|
+
local file="$1"
|
|
43
|
+
local pattern="$2"
|
|
44
|
+
local severity="$3"
|
|
45
|
+
local ok_msg="$4"
|
|
46
|
+
local msg_prefix="${5:-}"
|
|
47
|
+
|
|
48
|
+
local found=0
|
|
49
|
+
while IFS= read -r raw_line; do
|
|
50
|
+
local line="${raw_line%%:*}"
|
|
51
|
+
local match="${raw_line#*:}"
|
|
52
|
+
[[ -z "$match" ]] && continue
|
|
53
|
+
"$severity" "Line ${line}: ${msg_prefix}${match}"
|
|
54
|
+
found=1
|
|
55
|
+
done < <(grep -nE "$pattern" "$file" 2>/dev/null || true)
|
|
56
|
+
|
|
57
|
+
if ((found == 0)); then
|
|
58
|
+
clean "$ok_msg"
|
|
59
|
+
fi
|
|
60
|
+
return "$found"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function check_destructive_commands() {
|
|
64
|
+
_check_grep "$1" \
|
|
65
|
+
'rm\s+-rf\s+(/|\$|--no-preserve-root)|dd\s+(if=|of=).*/dev/sd|dd\s+(if=|of=).*/dev/nvme|mkfs\b' \
|
|
66
|
+
fatal "No destructive commands found"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function check_fork_bombs() {
|
|
70
|
+
_check_grep "$1" \
|
|
71
|
+
':\s*\(\)\s*\{.*\|.*:&\s*\}\s*;|\w+\(\)\s*\{\s*\w+\|\w+&\s*\}' \
|
|
72
|
+
fatal "No fork bomb patterns found" "Possible fork bomb: "
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function check_system_file_writes() {
|
|
76
|
+
_check_grep "$1" \
|
|
77
|
+
'>\s*/etc/(passwd|shadow|sudoers|group|hosts|crontab)|cat\s+>.*etc/' \
|
|
78
|
+
severe "No system file writes found" "Writes to system file: "
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function check_hardcoded_credentials() {
|
|
82
|
+
_check_grep "$1" \
|
|
83
|
+
'(password|passwd|pwd|api_key|apikey|secret|token|auth)\s*=\s*["\047][^"\047]{8,}' \
|
|
84
|
+
severe "No hardcoded credentials found" "Hardcoded credential: "
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function check_credential_formats() {
|
|
88
|
+
_check_grep "$1" \
|
|
89
|
+
'(AKIA[0-9A-Z]{16}|gh[pou]_[a-zA-Z0-9]{36}|sk-[a-zA-Z0-9]{20,}|AIza[0-9A-Za-z_-]{35}|xox[baprs]-[0-9]{10,}-[0-9]{10,}-[a-zA-Z0-9]{24})' \
|
|
90
|
+
severe "No recognised credential formats found" "Recognised credential format: "
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function check_insecure_permissions() {
|
|
94
|
+
_check_grep "$1" \
|
|
95
|
+
'chmod\s+777|chmod\s+-R.*777|chmod\s+a\+rwx' \
|
|
96
|
+
severe "No insecure permissions (chmod 777) found" "Insecure permissions: "
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function check_trap_injection() {
|
|
100
|
+
_check_grep "$1" \
|
|
101
|
+
'trap\s+.*\$' \
|
|
102
|
+
moderate "No trap injection patterns found" "Trap with variable: "
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function check_dangerous_sudo() {
|
|
106
|
+
_check_grep "$1" \
|
|
107
|
+
'sudo\s+(rm|dd|mkfs|chmod|kill|killall)' \
|
|
108
|
+
severe "No dangerous sudo commands found" "Dangerous sudo command: "
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function check_system_config_writes() {
|
|
112
|
+
_check_grep "$1" \
|
|
113
|
+
'(echo|printf|sed|awk).*>>.*\/etc\/(ssh|systemd|network)' \
|
|
114
|
+
moderate "No system config writes found" "System config write: "
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function check_dynamic_execution() {
|
|
118
|
+
_check_grep "$1" \
|
|
119
|
+
'eval\s+.*\$|source\s+.*\$[^({]' \
|
|
120
|
+
moderate "No dynamic execution patterns found" "Dynamic execution: "
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
###
|
|
124
|
+
### :::: Audit a single file :::: #####
|
|
125
|
+
###
|
|
126
|
+
|
|
127
|
+
function audit_file() {
|
|
128
|
+
local file="$1"
|
|
129
|
+
local issues=0
|
|
130
|
+
|
|
131
|
+
echo ""
|
|
132
|
+
echo -e "${BOLD}=== Security Audit: ${file} ===${RST}"
|
|
133
|
+
|
|
134
|
+
local checks=(
|
|
135
|
+
check_destructive_commands
|
|
136
|
+
check_fork_bombs
|
|
137
|
+
check_system_file_writes
|
|
138
|
+
check_hardcoded_credentials
|
|
139
|
+
check_credential_formats
|
|
140
|
+
check_insecure_permissions
|
|
141
|
+
check_trap_injection
|
|
142
|
+
check_dangerous_sudo
|
|
143
|
+
check_system_config_writes
|
|
144
|
+
check_dynamic_execution
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
for check in "${checks[@]}"; do
|
|
148
|
+
echo ""
|
|
149
|
+
echo -e "${BOLD}[$check]${RST}"
|
|
150
|
+
if ! "$check" "$file"; then
|
|
151
|
+
((issues++)) || true
|
|
152
|
+
fi
|
|
153
|
+
done
|
|
154
|
+
|
|
155
|
+
echo ""
|
|
156
|
+
if ((issues > 0)); then
|
|
157
|
+
echo -e "${RED}${BOLD}Found ${issues} categories with issues in ${file}${RST}"
|
|
158
|
+
return 1
|
|
159
|
+
else
|
|
160
|
+
echo -e "${LGRN}${BOLD}No security issues found in ${file}${RST}"
|
|
161
|
+
return 0
|
|
162
|
+
fi
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
###
|
|
166
|
+
### :::: Main :::: ####################
|
|
167
|
+
###
|
|
168
|
+
|
|
169
|
+
function main() {
|
|
170
|
+
local target="${1:-}"
|
|
171
|
+
|
|
172
|
+
if [[ -z "$target" ]]; then
|
|
173
|
+
echo "Usage: $0 <file|directory>" >&2
|
|
174
|
+
exit 1
|
|
175
|
+
fi
|
|
176
|
+
|
|
177
|
+
if [[ ! -e "$target" ]]; then
|
|
178
|
+
echo "Error: ${target} does not exist" >&2
|
|
179
|
+
exit 1
|
|
180
|
+
fi
|
|
181
|
+
|
|
182
|
+
local total_issues=0
|
|
183
|
+
|
|
184
|
+
if [[ -d "$target" ]]; then
|
|
185
|
+
# Scan all .sh files in directory
|
|
186
|
+
local file_count=0
|
|
187
|
+
while IFS= read -r -d '' file; do
|
|
188
|
+
((file_count++)) || true
|
|
189
|
+
if ! audit_file "$file"; then
|
|
190
|
+
((total_issues++)) || true
|
|
191
|
+
fi
|
|
192
|
+
done < <(find "$target" -maxdepth 1 -name '*.sh' -print0)
|
|
193
|
+
|
|
194
|
+
if ((file_count == 0)); then
|
|
195
|
+
echo "No .sh files found in ${target}" >&2
|
|
196
|
+
exit 1
|
|
197
|
+
fi
|
|
198
|
+
else
|
|
199
|
+
audit_file "$target" || total_issues=1
|
|
200
|
+
fi
|
|
201
|
+
|
|
202
|
+
if ((total_issues > 0)); then
|
|
203
|
+
exit 1
|
|
204
|
+
fi
|
|
205
|
+
exit 0
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
main "$@"
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: shell-test
|
|
3
|
+
description: Generate bashunit test files for bash scripts — analyse the target, scaffold a test file with setup/teardown, and write happy-path, edge, and error cases targeting ~80% public-function coverage. Use when creating tests for a shell script ("write tests", "test this script", "add test coverage"). For running tests use /shell-test-run; for debugging failing tests use shell-debugging.
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash
|
|
5
|
+
argument-hint: [script-path]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Shell Test Generation
|
|
9
|
+
|
|
10
|
+
Generates bashunit test files for bash scripts.
|
|
11
|
+
|
|
12
|
+
Scope: generate test files and verify they meet the ~80% public-function coverage target. For running the full suite in CI, use `/shell-test-run`. For debugging failing tests, use `shell-debugging` instead.
|
|
13
|
+
|
|
14
|
+
## Process
|
|
15
|
+
|
|
16
|
+
**Target script:** `$ARGUMENTS`
|
|
17
|
+
|
|
18
|
+
If `$ARGUMENTS` is not provided, ask the user which script to generate tests for. If `$ARGUMENTS` is a directory, generate tests for each `.sh` file in that directory.
|
|
19
|
+
|
|
20
|
+
1. **Read and analyse the target script** at `$ARGUMENTS`
|
|
21
|
+
2. **Create test file** — `tests/$(basename "$ARGUMENTS" .sh)-test.sh` (create `tests/` directory if it does not exist)
|
|
22
|
+
3. **Write test cases** — happy-path, edge, and error coverage for every public function
|
|
23
|
+
4. **Verify coverage** — run `scripts/public-coverage.sh`; if below target, return to step 3
|
|
24
|
+
5. **Show usage** — inform the user to run the full suite with `/shell-test-run`
|
|
25
|
+
|
|
26
|
+
### Step 1: Analyse the Target Script
|
|
27
|
+
|
|
28
|
+
Before writing any tests, identify:
|
|
29
|
+
|
|
30
|
+
- **Public functions** — functions intended to be called externally; test all of these
|
|
31
|
+
- **Private functions** — helper functions prefixed with `_` or `__`; test only if they contain non-trivial logic
|
|
32
|
+
- **Side effects** — file I/O, network calls, process creation, environment variable mutation
|
|
33
|
+
- **External dependencies** — calls to other scripts, CLI tools, or APIs
|
|
34
|
+
- **Main block** — code executed at source time (outside any function); determines sourcing strategy
|
|
35
|
+
|
|
36
|
+
### Step 2: Create the Test File
|
|
37
|
+
|
|
38
|
+
Place the test file at `tests/[script-name]-test.sh`. Create the `tests/` directory if it does not exist.
|
|
39
|
+
|
|
40
|
+
Use the `set_up_before_script()` function to source the target script. This keeps the sourcing path explicit and easy to adjust.
|
|
41
|
+
|
|
42
|
+
### Step 3: Write Test Cases
|
|
43
|
+
|
|
44
|
+
Write tests so **every public function** has happy-path, edge, and error coverage — this is the real target. Prioritise:
|
|
45
|
+
|
|
46
|
+
1. **Happy path** — each public function called with valid input, asserting expected output or exit code
|
|
47
|
+
2. **Edge cases** — empty input, whitespace, special characters, zero/null values (see `references/assertions.md` edge-case table)
|
|
48
|
+
3. **Error paths** — invalid input, missing arguments, failed preconditions
|
|
49
|
+
|
|
50
|
+
Aim for ~80% coverage of public-function behaviour.
|
|
51
|
+
|
|
52
|
+
For the complete assertion reference, consult `references/assertions.md`.
|
|
53
|
+
|
|
54
|
+
### Step 4: Verify Coverage
|
|
55
|
+
|
|
56
|
+
Run the public-function coverage check on the generated tests:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
scripts/public-coverage.sh "tests/$(basename "$ARGUMENTS" .sh)-test.sh" --coverage-paths "$(dirname "$ARGUMENTS")/"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
If the result is below the threshold (default 80%), the report names the uncovered public functions — return to **Step 3** and add happy/edge/error cases for them, then re-run. Stop when the target is met.
|
|
63
|
+
|
|
64
|
+
If it reports "no public functions", the target has no `<namespace>::` functions: either namespace them per the convention, or skip this check and rely on whole-file coverage (`/shell-test-run --coverage-min`).
|
|
65
|
+
|
|
66
|
+
### Step 5: Show Usage
|
|
67
|
+
|
|
68
|
+
After generating the test file, display a summary of generated tests and instruct the user to run them with `/shell-test-run`.
|
|
69
|
+
|
|
70
|
+
**Done when** every public function has happy-path, edge, and error tests; every assertion used is confirmed available in the installed bashunit (`bashunit doc assert`); the test file sources cleanly under `set_up_before_script()`; and main-block and side-effect patterns are handled. Verify coverage with `scripts/public-coverage.sh`.
|
|
71
|
+
|
|
72
|
+
### Coverage Target
|
|
73
|
+
|
|
74
|
+
Target: ~80% line coverage of **public functions** (`<namespace>::name`). bashunit's `--coverage`/`--coverage-min` report **whole-file** coverage — which includes main blocks, private helpers, and untestable code — so the skill measures the public-function figure directly in Step 4 (`scripts/public-coverage.sh`; `--min N` to change the threshold).
|
|
75
|
+
|
|
76
|
+
For all bashunit CLI flags, consult `references/assertions.md` -- Coverage section.
|
|
77
|
+
|
|
78
|
+
## Test Structure
|
|
79
|
+
|
|
80
|
+
bashunit provides four lifecycle hooks — `set_up_before_script`, `set_up`, `tear_down`, `tear_down_after_script`. See `references/test-template.md` for the full hook table and the complete test-file skeleton.
|
|
81
|
+
|
|
82
|
+
## Handling Common Patterns
|
|
83
|
+
|
|
84
|
+
### Scripts with a Main Block
|
|
85
|
+
|
|
86
|
+
Many bash scripts contain top-level code that runs on source (e.g. argument parsing, `main "$@"`). This code executes when the test file sources the script, causing unintended side effects.
|
|
87
|
+
|
|
88
|
+
Detect this pattern by looking for code outside function definitions. When present, wrap the main block in a guard:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# In the source script, wrap the main block:
|
|
92
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
93
|
+
main "$@"
|
|
94
|
+
fi
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
If modifying the source script is not possible, use environment variables to control behaviour:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
function set_up_before_script() {
|
|
101
|
+
export SKIP_MAIN=1
|
|
102
|
+
source path/to/script.sh
|
|
103
|
+
unset SKIP_MAIN
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Sourcing and Strict Mode
|
|
108
|
+
|
|
109
|
+
bashunit runs tests with strict mode **off** (`set +euo pipefail`) so a failing command doesn't abort before an assertion can inspect `$?`. But `shell-best-practices` mandates `set -euo pipefail` at the top of every script — so sourcing the script under test re-enables strict mode, and `failing_cmd; assert_general_error` aborts before the assertion runs. Save and restore shell options around the source (the default in the test template):
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
function set_up_before_script() {
|
|
113
|
+
local _opts
|
|
114
|
+
_opts=$(shopt -po errexit nounset pipefail 2>/dev/null || true)
|
|
115
|
+
source path/to/script.sh
|
|
116
|
+
eval "$_opts" # restore bashunit's non-strict execution model
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Also: when bashunit sources the test, `$0` is bashunit's binary, not the script under test — so a script that resolves its own directory via `$(dirname "$0")` computes the wrong path. Set the relevant path variable before sourcing.
|
|
121
|
+
|
|
122
|
+
### Scripts with Side Effects
|
|
123
|
+
|
|
124
|
+
Functions that write files, create directories, or modify global state require cleanup. Use `set_up()` and `tear_down()` for per-test isolation. Use `bashunit::temp_file` for a temporary file or `bashunit::temp_dir` for a directory:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
function set_up() {
|
|
128
|
+
TEMP_FILE=$(bashunit::temp_file)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function tear_down() {
|
|
132
|
+
rm -f "$TEMP_FILE"
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
For one-time resources (e.g. test databases, service instances), use `set_up_before_script()` and `tear_down_after_script()` instead.
|
|
137
|
+
|
|
138
|
+
### Scripts Using External Commands
|
|
139
|
+
|
|
140
|
+
When a function calls external tools (`curl`, `git`, `docker`, etc.), replace them with `bashunit::mock`. The simplest form feeds a fixed response:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
bashunit::mock curl <<< '{"status":"ok"}'
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
For conditional mocks, multi-line heredoc mocks, and the full pattern set, see `references/assertions.md` -- Mocking External Commands.
|
|
147
|
+
|
|
148
|
+
### Scripts with Environment Variables
|
|
149
|
+
|
|
150
|
+
Functions that read environment variables should be tested with both set and unset states. Set variables in `set_up()` and unset in `tear_down()` to prevent state leaking between tests:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
function set_up() {
|
|
154
|
+
export AWS_REGION="eu-west-1"
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function tear_down() {
|
|
158
|
+
unset AWS_REGION
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function test_deploy_uses_custom_region() {
|
|
162
|
+
local result
|
|
163
|
+
result=$(deploy_function)
|
|
164
|
+
assert_contains "eu-west-1" "$result"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function test_deploy_defaults_to_us_east_1() {
|
|
168
|
+
unset AWS_REGION
|
|
169
|
+
local result
|
|
170
|
+
result=$(deploy_function)
|
|
171
|
+
assert_contains "us-east-1" "$result"
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Coverage and Subshells
|
|
176
|
+
|
|
177
|
+
bashunit's coverage tracks the main shell only. A function called inside `$(...)`, a pipeline, or `( )` runs in a subshell — its body lines are **not** recorded, so public-function coverage reads 0% even when the test passes. Since Step 4 enforces public-function coverage, call every function under test in the main shell and capture its output through a file:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
function test_myapp_add() {
|
|
181
|
+
myapp::add 2 3 > "$TEMP_FILE" # main-shell call -> body lines covered
|
|
182
|
+
assert_equals "5" "$(<"$TEMP_FILE")"
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`$(<file)` reads the file without re-running the function. Functions you are *not* measuring coverage on may still use `result=$(fn)`.
|
|
187
|
+
|
|
188
|
+
## Test Naming Conventions
|
|
189
|
+
|
|
190
|
+
Follow a consistent naming pattern to make test intent clear:
|
|
191
|
+
|
|
192
|
+
| Pattern | Use For | Example |
|
|
193
|
+
|---------|---------|---------|
|
|
194
|
+
| `test_[fn]_returns_[expected]` | Happy path output | `test_square_returns_product` |
|
|
195
|
+
| `test_[fn]_defaults_to_[value]` | Default/fallback behaviour | `test_greet_defaults_to_world` |
|
|
196
|
+
| `test_[fn]_handles_[edge_case]` | Boundary or unusual input | `test_parse_handles_empty_input` |
|
|
197
|
+
| `test_[fn]_fails_on_[condition]` | Error/invalid input | `test_square_fails_on_missing_arg` |
|
|
198
|
+
| `test_[fn]_with_[setup]` | When a specific state is needed | `test_deploy_with_custom_region` |
|
|
199
|
+
|
|
200
|
+
For descriptive test output, use `set_test_title` inside the test function:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
function test_parse_handles_empty_input() {
|
|
204
|
+
set_test_title "Parser gracefully handles empty input string"
|
|
205
|
+
parse ""
|
|
206
|
+
assert_general_error
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Assertions
|
|
211
|
+
|
|
212
|
+
For the complete assertion reference with usage patterns, edge-case testing, and coverage CLI flags, consult `references/assertions.md`.
|
|
213
|
+
|
|
214
|
+
## Additional Resources
|
|
215
|
+
|
|
216
|
+
### Reference Files
|
|
217
|
+
|
|
218
|
+
- `references/test-template.md` -- Complete test file structure with setup/teardown
|
|
219
|
+
- `references/assertions.md` -- Full assertion reference with usage patterns and edge-case table
|
|
220
|
+
- `references/advanced-patterns.md` -- Data providers, spies, and snapshot testing
|
|
221
|
+
|
|
222
|
+
Always read all references and examples before generating tests.
|
|
223
|
+
|
|
224
|
+
### Example Files
|
|
225
|
+
|
|
226
|
+
- `examples/test-example.md` -- End-to-end example showing input script, generated tests, and execution
|
|
227
|
+
|
|
228
|
+
### Scripts
|
|
229
|
+
|
|
230
|
+
- `scripts/public-coverage.sh` -- Measures line coverage scoped to public (`<namespace>::`) functions, excluding private `_` helpers and non-function code. Usage: `scripts/public-coverage.sh [--min N] [bashunit args...]`
|
|
231
|
+
|
|
232
|
+
## Integration
|
|
233
|
+
|
|
234
|
+
- **`/shell-test-run`** command — Run generated tests
|
|
235
|
+
- **`shell-expert`** agent — Complex test scenarios
|
|
236
|
+
- **`shell-review`** skill — Test quality review
|
|
237
|
+
- **`shell-debugging`** skill — Debug failing tests
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Example: Generating Tests
|
|
2
|
+
|
|
3
|
+
## Input Script
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
#!/usr/bin/env bash
|
|
7
|
+
# greeter.sh
|
|
8
|
+
|
|
9
|
+
myapp::greet() {
|
|
10
|
+
local name="${1:-World}"
|
|
11
|
+
echo "Hello, ${name}!"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
myapp::square() {
|
|
15
|
+
local num="$1"
|
|
16
|
+
if [[ -z "$num" ]]; then
|
|
17
|
+
echo "Error: argument required" >&2
|
|
18
|
+
return 1
|
|
19
|
+
fi
|
|
20
|
+
echo "$((num * num))"
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Generated Test File
|
|
25
|
+
|
|
26
|
+
Public functions are called in the main shell with output redirected to a temp file, so bashunit records their body coverage. A function run inside `$(...)` or a pipe does not register coverage — see **Coverage and Subshells** in the skill.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
#!/usr/bin/env bash
|
|
30
|
+
# tests/greeter-test.sh
|
|
31
|
+
|
|
32
|
+
function set_up() {
|
|
33
|
+
OUT=$(bashunit::temp_file)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function set_up_before_script() {
|
|
37
|
+
source src/greeter.sh
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function test_greet_with_name() {
|
|
41
|
+
myapp::greet "Alice" > "$OUT"
|
|
42
|
+
assert_equals "Hello, Alice!" "$(<"$OUT")"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function test_greet_defaults_to_world() {
|
|
46
|
+
myapp::greet > "$OUT"
|
|
47
|
+
assert_equals "Hello, World!" "$(<"$OUT")"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function test_square_positive_number() {
|
|
51
|
+
myapp::square 5 > "$OUT"
|
|
52
|
+
assert_equals "25" "$(<"$OUT")"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function test_square_zero() {
|
|
56
|
+
myapp::square 0 > "$OUT"
|
|
57
|
+
assert_equals "0" "$(<"$OUT")"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function test_square_fails_without_argument() {
|
|
61
|
+
myapp::square
|
|
62
|
+
assert_general_error
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Running Tests
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Verify public-function coverage meets the 80% target (Step 4)
|
|
70
|
+
scripts/public-coverage.sh tests/ --coverage-paths src/
|
|
71
|
+
|
|
72
|
+
# Run the full suite
|
|
73
|
+
/shell-test-run tests/greeter-test.sh
|
|
74
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Advanced Testing Patterns
|
|
2
|
+
|
|
3
|
+
Data providers, spies, and snapshots for richer tests. Verified against bashunit 0.40.0 — confirm against your installed version with `bashunit doc assert`.
|
|
4
|
+
|
|
5
|
+
## Data Providers (table-driven tests)
|
|
6
|
+
|
|
7
|
+
Run a test function once per row of data; each row's words become `$1`, `$2`, …:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
function dp_add() {
|
|
11
|
+
echo "2 3 5" # $1=2 $2=3 expected=$3
|
|
12
|
+
echo "10 10 20"
|
|
13
|
+
echo "0 0 0"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# data_provider dp_add
|
|
17
|
+
function test_add() {
|
|
18
|
+
assert_equals "$3" "$(( $1 + $2 ))"
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The `# data_provider <fn>` annotation sits directly above the test function. Each line the provider echoes is one test run.
|
|
23
|
+
|
|
24
|
+
## Spies (verify a function was called)
|
|
25
|
+
|
|
26
|
+
`bashunit::spy` wraps a function to track its calls without changing its behaviour; the `assert_have_been_called*` assertions then inspect the calls:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
function test_deploy_calls_notify() {
|
|
30
|
+
bashunit::spy notify
|
|
31
|
+
myapp::deploy
|
|
32
|
+
assert_have_been_called notify
|
|
33
|
+
assert_have_been_called_times 1 notify
|
|
34
|
+
assert_have_been_called_with "success" notify
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- `bashunit::mock` **replaces** a command with fixed output; `bashunit::spy` **observes** a function while keeping its behaviour.
|
|
39
|
+
- The spy command is `bashunit::spy` (a bare `spy` is not available in user tests).
|
|
40
|
+
|
|
41
|
+
## Snapshot Testing
|
|
42
|
+
|
|
43
|
+
For stable multi-line output (help text, generated reports), snapshot it once and assert future runs match:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
function test_help_output() {
|
|
47
|
+
myapp::print_help > "$TEMP_FILE"
|
|
48
|
+
assert_match_snapshot "$(<"$TEMP_FILE")"
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
After intentional output changes, refresh the stored snapshot: `bashunit --update-snapshots tests/`.
|