diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 538d7a4e..3eff9398 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -241,12 +241,14 @@ Complete end-to-end workflow from issue to PR: - Use speculative/unproven patterns - Commit without tests passing - Batch unrelated changes in one PR +- Create a PR without using the `/pr` skill (even if user says "create pr") ### Always: - Write tests before implementation - Use existing patterns from `tests/**` and `src/**` - Minimal code in GREEN phase - Keep tests passing during REFACTOR +- Update CHANGELOG.md before creating a PR (for user-facing changes) - Run quality checks before committing - Update CHANGELOG.md for user-visible changes - Maintain Bash 3.0+ compatibility diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..4fa8942f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "includeCoAuthoredBy": false +} diff --git a/.claude/skills/pr/SKILL.md b/.claude/skills/pr/SKILL.md new file mode 100644 index 00000000..898a6531 --- /dev/null +++ b/.claude/skills/pr/SKILL.md @@ -0,0 +1,111 @@ +--- +name: pr +description: Push branch and create a GitHub PR with concise, issue-linked description +user-invocable: true +argument-hint: "[#issue]" +disable-model-invocation: true +allowed-tools: Bash, Read, Grep, Glob +--- + +# Create Pull Request + +Push branch and create a PR with a concise, issue-linked description. + +> **IMPORTANT:** This skill MUST be used for ALL pull request creation — even when the user says "create pr" without `/pr`. Never create a PR without following these steps. + +## Current Branch Context +- Branch: !`git branch --show-current` +- Commits: !`git log main..HEAD --oneline 2>/dev/null` +- Changed files: !`git diff main..HEAD --stat 2>/dev/null` + +## Arguments +- `$ARGUMENTS` - Issue reference (optional, e.g., `#42` or `42`). If provided, the PR will be linked to this issue. + +## Instructions + +1. **Review the branch context above** — the commits and changed files are already loaded. + +2. **MANDATORY: Update CHANGELOG.md** — Read `CHANGELOG.md` and check the `## Unreleased` section. If the changes from this branch are NOT already listed there, you MUST update it before proceeding. **Do NOT skip this step. Do NOT proceed to push without verifying.** + - Add entries under the appropriate subsection (`### Added`, `### Changed`, `### Fixed`, etc.) + - Reference issue numbers where applicable (e.g., `(Issue #123)`) + - Commit the update: + ```bash + git add CHANGELOG.md && git commit -m "docs: update changelog" + ``` + +3. **Push branch**: + ```bash + git push -u origin HEAD + ``` + - The `pre-push` git hook automatically runs the full test suite (BE & FE in parallel). + - If the hook fails, read the output, fix the issue, commit the fix, and retry the push. Do NOT use `--no-verify` to bypass. + +4. **Generate PR title**: + - If `$ARGUMENTS` contains an issue number, fetch the issue title: + ```bash + gh issue view --json title -q '.title' + ``` + - PR title format: `(): ` (conventional commit style, under 70 chars) + - Derive the type from the branch prefix (`feat/` → feat, `fix/` → fix, `docs/` → docs) + +5. **Create PR** using the template from `.github/PULL_REQUEST_TEMPLATE.md`: + ```bash + gh pr create --title "" --assignee @me --label "<label>" --body "$(cat <<'EOF' + ## 🤔 Background + + Related #<issue-number> + + <1-2 sentences: motivation and context for the changes> + + ## 💡 Changes + + - <bullet 1: what changed and why> + - <bullet 2> + - <bullet 3> (optional) + - <bullet 4> (optional) + EOF + )" + ``` + + **MANDATORY:** Always follow the PR template structure (`## 🤔 Background` + `## 💡 Changes`). Never use a different format. + + **Assignee:** Always assign to `@me` (the PR creator). + + **Labels:** Add the single most relevant label based on the branch prefix and change context: + - `bug` — branch starts with `fix/` and addresses a defect + - `enhancement` — branch starts with `feat/` or adds new functionality + - `documentation` — branch starts with `docs/` or only changes docs + - `refactor` — code restructuring with no behavior change + - `ui` — visual/frontend-only changes + - `investigation` — spikes, research, or exploratory work + + **Body guidelines:** + - **Background**: Link the issue with `Related #<number>`, then 1-2 sentences of context. **NEVER use `Closes` or `Fixes`.** + - **Changes**: 2-4 short bullet points. Focus on *what* and *why*, not implementation details. + - **No file lists, no class names, no code snippets** in the body. + - Keep the entire body under 15 lines. + +6. **Move issue to "In Review"** in GitHub Project (if issue number provided): + ```bash + # Read project config from .claude/github-project.json + ITEM_ID=$(gh project item-list PROJECT_NUMBER --owner OWNER --format json \ + | jq -r '.items[] | select(.content.number == ISSUE_NUMBER) | .id') + + gh project item-edit \ + --id "$ITEM_ID" \ + --project-id "PROJECT_ID" \ + --field-id "STATUS_FIELD_ID" \ + --single-select-option-id "IN_REVIEW_OPTION_ID" + ``` + + **Note:** Requires `project` scope. Run `gh auth refresh -s project` if needed. + +7. **Report the PR URL** to the user. + +## Example Usage + +``` +/pr +/pr #42 +/pr 15 +``` diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 849aeb46..ee8f5a66 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,12 +34,26 @@ jobs: run: make test windows: - name: "On windows (${{ matrix.test_chunk }})" + name: "On windows (${{ matrix.name }})" timeout-minutes: 10 runs-on: windows-latest strategy: matrix: - test_chunk: [acceptance, functional, unit] + include: + - name: "acceptance a-l" + test_path: "tests/acceptance/bashunit_[a-l]*_test.sh" + - name: "acceptance m-z" + test_path: "tests/acceptance/bashunit_[m-z]*_test.sh tests/acceptance/[i-p]*_test.sh" + - name: functional + test_path: "tests/functional/*_test.sh" + - name: "unit a-b" + test_path: "tests/unit/[a-b]*_test.sh" + - name: "unit c" + test_path: "tests/unit/c*_test.sh" + - name: "unit d-p" + test_path: "tests/unit/[d-p]*_test.sh" + - name: "unit r-z" + test_path: "tests/unit/[r-z]*_test.sh" fail-fast: false steps: - name: Checkout code @@ -50,7 +64,7 @@ jobs: - name: Run tests shell: bash run: | - ./bashunit --parallel tests/${{ matrix.test_chunk }}/*_test.sh + ./bashunit --parallel ${{ matrix.test_path }} alpine: name: "On alpine-latest" diff --git a/CHANGELOG.md b/CHANGELOG.md index c5e4af5b..7fbf443a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Added +- Add `assert_have_been_called_nth_with` assertion for verifying arguments on the Nth invocation of a spy (Issue #172) + +### Changed +- Split Windows CI test jobs into parallel chunks to avoid timeouts + ## [0.33.0](https://github.com/TypedDevs/bashunit/compare/0.32.0...0.33.0) - 2026-02-15 ### Changed diff --git a/src/test_doubles.sh b/src/test_doubles.sh index 2ea30e26..ce829a9a 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -156,6 +156,49 @@ function assert_have_been_called_times() { bashunit::state::add_assertions_passed } +function assert_have_been_called_nth_with() { + local nth=$1 + local command=$2 + shift 2 + local expected="$*" + + local variable + variable="$(bashunit::helper::normalize_variable_name "$command")" + local times_file_var="${variable}_times_file" + local file_var="${variable}_params_file" + local label + label="$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")" + + local times=0 + if [ -f "${!times_file_var-}" ]; then + times=$(cat "${!times_file_var}" 2>/dev/null || echo 0) + fi + + if [ "$nth" -gt "$times" ]; then + bashunit::state::add_assertions_failed + bashunit::console_results::print_failed_test "${label}" \ + "expected call" "at index ${nth} but" "only called ${times} times" + return + fi + + local line="" + if [ -f "${!file_var-}" ]; then + line=$(sed -n "${nth}p" "${!file_var}" 2>/dev/null || true) + fi + + local raw + IFS=$'\x1e' read -r raw _ <<<"$line" || true + + if [ "$expected" != "$raw" ]; then + bashunit::state::add_assertions_failed + bashunit::console_results::print_failed_test "${label}" \ + "$expected" "but got " "$raw" + return + fi + + bashunit::state::add_assertions_passed +} + function assert_not_called() { local command=$1 local label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" diff --git a/tests/unit/test_doubles_test.sh b/tests/unit/test_doubles_test.sh index 6db2a795..169bf6b2 100644 --- a/tests/unit/test_doubles_test.sh +++ b/tests/unit/test_doubles_test.sh @@ -177,3 +177,40 @@ function test_spy_with_pipe_in_arguments() { assert_have_been_called_with grep '-E foo|bar' } + +function test_successful_spy_nth_called_with() { + bashunit::spy ps + + ps first_a first_b + ps second + ps third + + assert_have_been_called_nth_with 1 ps "first_a first_b" + assert_have_been_called_nth_with 2 ps "second" + assert_have_been_called_nth_with 3 ps "third" +} + +function test_unsuccessful_spy_nth_called_with() { + bashunit::spy ps + + ps first + ps second + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful spy nth called with" \ + "wrong" "but got " "first")" \ + "$(assert_have_been_called_nth_with 1 ps "wrong")" +} + +function test_unsuccessful_spy_nth_called_with_invalid_index() { + bashunit::spy ps + + ps first + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful spy nth called with invalid index" \ + "expected call" "at index 5 but" "only called 1 times")" \ + "$(assert_have_been_called_nth_with 5 ps "first")" +}