From 2e5430b9287a4f517b151ae608bf0d1177a2200c Mon Sep 17 00:00:00 2001 From: penpal Date: Tue, 26 May 2026 20:48:11 +0545 Subject: [PATCH 1/8] test: Add end-to-end hook test runner Introduce `tests/e2e/run_e2e_tests.sh`, a behavioral test runner that exercises each hook the way a user would -- through `pre-commit` against fixture files -- and compares the result against a committed golden tree. It auto-discovers `cases///`, builds a temp git repo from `input/`, renders the case's `.pre-commit-config.yaml` (`__PCT_REPO__` -> checkout root), runs the hook via `pre-commit run --all-files`, then asserts the exit code matches `expected_returncode` (default 0) and the working tree is byte-identical to `expected/`. The tree compare uses `git diff --no-index` so it works with the BusyBox `diff` shipped in the project image. A `requires` file skips a case when a needed CLI tool is absent. Part of #823. Signed-off-by: penpal --- tests/e2e/run_e2e_tests.sh | 144 +++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100755 tests/e2e/run_e2e_tests.sh diff --git a/tests/e2e/run_e2e_tests.sh b/tests/e2e/run_e2e_tests.sh new file mode 100755 index 000000000..07b8ee389 --- /dev/null +++ b/tests/e2e/run_e2e_tests.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# End-to-end tests for the hooks in this repo (see GH issue #823). +# +# Each test case lives in `tests/e2e/cases///` and contains: +# .pre-commit-config.yaml - `repo: local` config; `__PCT_REPO__` is replaced +# with the repo root so `entry` resolves. +# input/ - working tree the hook runs against. +# expected/ - the working tree as it should look AFTER the hook +# ran (input files + any generated/modified files). +# expected_returncode - optional; expected `pre-commit run` exit code (default 0). +# requires - optional; one CLI tool per line. Case is SKIPPED +# if any is missing from PATH. +# +# A case passes when the `pre-commit run` exit code matches `expected_returncode` +# AND the resulting working tree is byte-identical to `expected/`. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +readonly SCRIPT_DIR +readonly CASES_DIR="$SCRIPT_DIR/cases" + +REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" +readonly REPO_ROOT + +# Colors (disabled when not a TTY). +if [[ -t 1 ]]; then + C_RED=$'\033[31m' C_GREEN=$'\033[32m' C_YELLOW=$'\033[33m' C_RESET=$'\033[0m' +else + C_RED='' C_GREEN='' C_YELLOW='' C_RESET='' +fi +readonly C_RED C_GREEN C_YELLOW C_RESET + +declare -i passed=0 failed=0 skipped=0 +declare -a summary=() + +# Run a single case dir. Returns non-zero on failure. +function run_case { + local case_dir="$1" + local case_name hook_id test_id + case_name="$(basename "$case_dir")" + hook_id="$(basename "$(dirname "$case_dir")")" + test_id="${hook_id}/${case_name}" + + # Skip when a required tool is absent. + if [[ -f "$case_dir/requires" ]]; then + local tool + while read -r tool || [[ -n $tool ]]; do + [[ -z $tool || $tool == \#* ]] && continue + if ! command -v "$tool" > /dev/null 2>&1; then + summary+=("${C_YELLOW}SKIP${C_RESET} ${test_id} (missing: ${tool})") + skipped+=1 + return 0 + fi + done < "$case_dir/requires" + fi + + local work config log + work="$(mktemp -d)" + config="$(mktemp)" + log="$(mktemp)" + # shellcheck disable=SC2064 # expand paths now, on purpose + trap "rm -rf '$work' '$config' '$log' '$log.diff'" RETURN + + # Materialize the input working tree as a git repo (hooks and pre-commit + # both expect one; the wrapper hook calls `git rev-parse --show-toplevel`). + cp -R "$case_dir/input/." "$work/" + git -C "$work" init -q + git -C "$work" add -A + git -C "$work" \ + -c user.email=e2e@example.invalid -c user.name='e2e' \ + commit -qm 'e2e fixture' --no-verify + + # Render the per-case config, pointing `entry` at this checkout. + sed "s|__PCT_REPO__|${REPO_ROOT}|g" \ + "$case_dir/.pre-commit-config.yaml" > "$config" + + local actual_rc=0 + ( + cd "$work" + pre-commit run --config "$config" --all-files + ) > "$log" 2>&1 || actual_rc=$? + + local expected_rc=0 + [[ -f "$case_dir/expected_returncode" ]] && + expected_rc="$(cat "$case_dir/expected_returncode")" + + # Drop the throwaway git dir so the tree compare only sees fixture output. + # `git diff --no-index` is used instead of `diff -r` because the project + # image ships BusyBox `diff`, which lacks `--exclude` and `-u`. + rm -rf "$work/.git" + + local ok=true reason='' + if [[ $actual_rc -ne $expected_rc ]]; then + ok=false + reason="exit code ${actual_rc}, expected ${expected_rc}" + elif ! git --no-pager diff --no-index --exit-code \ + "$case_dir/expected" "$work" > "$log.diff" 2>&1; then + ok=false + reason='output differs from expected/' + fi + + if [[ $ok == true ]]; then + summary+=("${C_GREEN}PASS${C_RESET} ${test_id}") + passed+=1 + return 0 + fi + + summary+=("${C_RED}FAIL${C_RESET} ${test_id} (${reason})") + failed+=1 + echo "${C_RED}--- FAIL: ${test_id} (${reason}) ---${C_RESET}" + echo "pre-commit output:" + sed 's/^/ /' "$log" + if [[ -s "$log.diff" ]]; then + echo "diff (expected vs actual):" + sed 's/^/ /' "$log.diff" + fi + rm -f "$log.diff" + return 1 +} + +function main { + if [[ ! -d $CASES_DIR ]]; then + echo "No cases dir at ${CASES_DIR}" >&2 + exit 1 + fi + + local case_dir + while IFS= read -r -d '' case_dir; do + run_case "$case_dir" || true + done < <(find "$CASES_DIR" -mindepth 2 -maxdepth 2 -type d -print0 | sort -z) + + echo + echo '==== e2e summary ====' + local line + for line in "${summary[@]}"; do + echo " $line" + done + echo " ${passed} passed, ${failed} failed, ${skipped} skipped" + + [[ $failed -eq 0 ]] +} + +main "$@" From 73d823135bb874f39e0091ffc18c949eff1ea483 Mon Sep 17 00:00:00 2001 From: penpal Date: Tue, 26 May 2026 22:09:37 +0545 Subject: [PATCH 2/8] test: Add terraform_wrapper_module_for_each e2e case Add `generates-wrapper-for-root-module`: a root module (with no `provider_meta`) is wrapped, and the generated `wrappers/{main,outputs,variables,versions}.tf` plus `README.md` are checked against the golden tree. Exits 0 (only new files are created). Part of #823. Signed-off-by: penpal --- .../.pre-commit-config.yaml | 12 +++ .../expected/main.tf | 7 ++ .../expected/versions.tf | 9 ++ .../expected/wrappers/README.md | 100 ++++++++++++++++++ .../expected/wrappers/main.tf | 7 ++ .../expected/wrappers/outputs.tf | 5 + .../expected/wrappers/variables.tf | 11 ++ .../expected/wrappers/versions.tf | 9 ++ .../input/main.tf | 7 ++ .../input/versions.tf | 9 ++ 10 files changed, 176 insertions(+) create mode 100644 tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/.pre-commit-config.yaml create mode 100644 tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/main.tf create mode 100644 tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/versions.tf create mode 100644 tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/README.md create mode 100644 tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/main.tf create mode 100644 tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/outputs.tf create mode 100644 tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/variables.tf create mode 100644 tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/versions.tf create mode 100644 tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/input/main.tf create mode 100644 tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/input/versions.tf diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/.pre-commit-config.yaml b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/.pre-commit-config.yaml new file mode 100644 index 000000000..fd9642611 --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +repos: +- repo: local + hooks: + - id: terraform_wrapper_module_for_each + name: terraform_wrapper_module_for_each + language: script + entry: __PCT_REPO__/hooks/terraform_wrapper_module_for_each.sh + files: \.(tf|tofu)$ + args: + - --args=--module-repo-org=terraform-aws-modules + - --args=--module-repo-shortname=s3-bucket + - --args=--module-repo-provider=aws diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/main.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/main.tf new file mode 100644 index 000000000..6609005e8 --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/main.tf @@ -0,0 +1,7 @@ +variable "name" { + type = string +} + +output "id" { + value = "x" +} diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/versions.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/versions.tf new file mode 100644 index 000000000..dda48f783 --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.3" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.27" + } + } +} diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/README.md b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/README.md new file mode 100644 index 000000000..4218b5b94 --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/README.md @@ -0,0 +1,100 @@ +# Wrapper for the root module + +The configuration in this directory contains an implementation of a single module wrapper pattern, which allows managing several copies of a module in places where using the native Terraform 0.13+ `for_each` feature is not feasible (e.g., with Terragrunt). + +You may want to use a single Terragrunt configuration file to manage multiple resources without duplicating `terragrunt.hcl` files for each copy of the same module. + +This wrapper does not implement any extra functionality. + +## Usage with Terragrunt + +`terragrunt.hcl`: + +```hcl +terraform { + source = "tfr:///terraform-aws-modules/s3-bucket/aws//wrappers" + # Alternative source: + # source = "git::git@github.com:terraform-aws-modules/terraform-aws-s3-bucket.git//wrappers?ref=master" +} + +inputs = { + defaults = { # Default values + create = true + tags = { + Terraform = "true" + Environment = "dev" + } + } + + items = { + my-item = { + # omitted... can be any argument supported by the module + } + my-second-item = { + # omitted... can be any argument supported by the module + } + # omitted... + } +} +``` + +## Usage with Terraform + +```hcl +module "wrapper" { + source = "terraform-aws-modules/s3-bucket/aws//wrappers" + + defaults = { # Default values + create = true + tags = { + Terraform = "true" + Environment = "dev" + } + } + + items = { + my-item = { + # omitted... can be any argument supported by the module + } + my-second-item = { + # omitted... can be any argument supported by the module + } + # omitted... + } +} +``` + +## Example: Manage multiple S3 buckets in one Terragrunt layer + +`eu-west-1/s3-buckets/terragrunt.hcl`: + +```hcl +terraform { + source = "tfr:///terraform-aws-modules/s3-bucket/aws//wrappers" + # Alternative source: + # source = "git::git@github.com:terraform-aws-modules/terraform-aws-s3-bucket.git//wrappers?ref=master" +} + +inputs = { + defaults = { + force_destroy = true + + attach_elb_log_delivery_policy = true + attach_lb_log_delivery_policy = true + attach_deny_insecure_transport_policy = true + attach_require_latest_tls_policy = true + } + + items = { + bucket1 = { + bucket = "my-random-bucket-1" + } + bucket2 = { + bucket = "my-random-bucket-2" + tags = { + Secure = "probably" + } + } + } +} +``` diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/main.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/main.tf new file mode 100644 index 000000000..caca9f4fb --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/main.tf @@ -0,0 +1,7 @@ +module "wrapper" { + source = "../" + + for_each = var.items + + name = try(each.value.name, var.defaults.name) +} diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/outputs.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/outputs.tf new file mode 100644 index 000000000..ec6da5f4a --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/outputs.tf @@ -0,0 +1,5 @@ +output "wrapper" { + description = "Map of outputs of a wrapper." + value = module.wrapper + # sensitive = false # No sensitive module output found +} diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/variables.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/variables.tf new file mode 100644 index 000000000..a6ea0962c --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/variables.tf @@ -0,0 +1,11 @@ +variable "defaults" { + description = "Map of default values which will be used for each item." + type = any + default = {} +} + +variable "items" { + description = "Maps of items to create a wrapper from. Values are passed through to the module." + type = any + default = {} +} diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/versions.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/versions.tf new file mode 100644 index 000000000..dda48f783 --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.3" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.27" + } + } +} diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/input/main.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/input/main.tf new file mode 100644 index 000000000..6609005e8 --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/input/main.tf @@ -0,0 +1,7 @@ +variable "name" { + type = string +} + +output "id" { + value = "x" +} diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/input/versions.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/input/versions.tf new file mode 100644 index 000000000..dda48f783 --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/input/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.3" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.27" + } + } +} From e1d2bceb44c00ca4794e4e1a442b1957ce23d8a9 Mon Sep 17 00:00:00 2001 From: penpal Date: Wed, 27 May 2026 10:26:54 +0545 Subject: [PATCH 3/8] test: Add terraform_fmt e2e case Add `reformats-misaligned-hcl`: misaligned HCL is rewritten to canonical style. The hook modifies a tracked file, so `pre-commit` exits 1 (`expected_returncode=1`). The case requires `terraform`. Part of #823. Signed-off-by: penpal --- .../reformats-misaligned-hcl/.pre-commit-config.yaml | 8 ++++++++ .../reformats-misaligned-hcl/expected/main.tf | 8 ++++++++ .../reformats-misaligned-hcl/expected_returncode | 1 + .../terraform_fmt/reformats-misaligned-hcl/input/main.tf | 8 ++++++++ .../cases/terraform_fmt/reformats-misaligned-hcl/requires | 1 + 5 files changed, 26 insertions(+) create mode 100644 tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/.pre-commit-config.yaml create mode 100644 tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/expected/main.tf create mode 100644 tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/expected_returncode create mode 100644 tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/input/main.tf create mode 100644 tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/requires diff --git a/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/.pre-commit-config.yaml b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/.pre-commit-config.yaml new file mode 100644 index 000000000..1a62e256e --- /dev/null +++ b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +repos: +- repo: local + hooks: + - id: terraform_fmt + name: terraform_fmt + language: script + entry: __PCT_REPO__/hooks/terraform_fmt.sh + files: \.(tf|tofu)$ diff --git a/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/expected/main.tf b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/expected/main.tf new file mode 100644 index 000000000..8eeaf9f08 --- /dev/null +++ b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/expected/main.tf @@ -0,0 +1,8 @@ +variable "name" { + type = string + default = "demo" +} + +output "id" { + value = var.name +} diff --git a/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/expected_returncode b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/expected_returncode new file mode 100644 index 000000000..d00491fd7 --- /dev/null +++ b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/expected_returncode @@ -0,0 +1 @@ +1 diff --git a/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/input/main.tf b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/input/main.tf new file mode 100644 index 000000000..f7709b89b --- /dev/null +++ b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/input/main.tf @@ -0,0 +1,8 @@ +variable "name" { +type=string + default = "demo" +} + +output "id" { + value = var.name +} diff --git a/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/requires b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/requires new file mode 100644 index 000000000..5ac7c5753 --- /dev/null +++ b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/requires @@ -0,0 +1 @@ +terraform From b68074d52650854d3fbf1862d16df06e13f624d8 Mon Sep 17 00:00:00 2001 From: penpal Date: Wed, 27 May 2026 11:02:19 +0545 Subject: [PATCH 4/8] ci: Run e2e hook tests on every PR Add `.github/workflows/e2e-tests.yaml`. Checkout runs on the host (the JS action needs Node), then the suite runs inside the published multi-tool image (`ghcr.io/antonbabenko/pre-commit-terraform:latest`), which supplies terraform/hcledit/pre-commit while the hook and test code come from the checkout -- so the image tag need not be in sync. Part of #823. Signed-off-by: penpal --- .github/workflows/e2e-tests.yaml | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/e2e-tests.yaml diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml new file mode 100644 index 000000000..ddb0a57de --- /dev/null +++ b/.github/workflows/e2e-tests.yaml @@ -0,0 +1,52 @@ +name: E2E hook tests + +on: + merge_group: + pull_request: + push: + branches-ignore: + - dependabot/** # Dependabot always creates PRs + - renovate/** # Our Renovate setup always creates PRs + - gh-readonly-queue/** # Temporary merge queue-related GH-made branches + - pre-commit-ci-update-config # pre-commit.ci always creates a PR + +permissions: + contents: read + +concurrency: + group: >- + ${{ github.workflow }}-${{ github.ref_type }}-${{ + github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + e2e: + name: Run e2e hook tests + runs-on: ubuntu-latest + timeout-minutes: 10 + + # The published image bundles every tool the hooks need (terraform, + # hcledit, pre-commit, git, ...). Hook and test code come from the checkout + # below, so the image tag only needs to provide tooling, not be in sync. + env: + IMAGE: ghcr.io/antonbabenko/pre-commit-terraform:latest + + steps: + - name: Check out src from Git + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Pull the multi-tool image + run: docker pull "${IMAGE}" + + # Checkout happens on the host (needs Node for the JS action); the tests + # run inside the image, which the alpine-based image can't host directly. + - name: Run e2e tests inside the image + run: >- + docker run --rm + --volume "${GITHUB_WORKSPACE}:/lint" + --workdir /lint + --entrypoint bash + "${IMAGE}" + tests/e2e/run_e2e_tests.sh From af727dadceb5b30c906c6160b56cb1ea1dc330b9 Mon Sep 17 00:00:00 2001 From: penpal Date: Wed, 27 May 2026 19:33:48 +0545 Subject: [PATCH 5/8] docs: Document the e2e hook test harness Add `tests/e2e/README.md` describing the case layout, how to run the suite natively and inside the project image, and how to add a new case. Part of #823. Signed-off-by: penpal --- tests/e2e/README.md | 88 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/e2e/README.md diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 000000000..cf532ee2e --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,88 @@ +# End-to-end hook tests + +Behavioral tests that run the hooks the same way a user would — through +`pre-commit` against real fixture files — and compare the result against a +committed "golden" output. See [issue #823][issue-823]. + +## Layout + +```text +tests/e2e/ + run_e2e_tests.sh # the runner + cases/// + .pre-commit-config.yaml # repo:local config; __PCT_REPO__ -> repo root + input/ # working tree the hook runs against + expected/ # working tree as it should look AFTER the hook + expected_returncode # optional, default 0 (fixing hooks exit 1) + requires # optional, one CLI tool per line; SKIP if missing +``` + +A case passes when the `pre-commit run` exit code matches `expected_returncode` +**and** the resulting working tree is byte-identical to `expected/`. + +Note: hooks that modify tracked files in place (e.g. `terraform_fmt`) make +`pre-commit` exit `1` ("files were modified by this hook"), so those cases set +`expected_returncode` to `1`. Hooks that only generate new files (e.g. +`terraform_wrapper_module_for_each`) leave tracked files untouched and exit `0`. + +## Prerequisites + +The runner itself needs `pre-commit` and `git`. Each case additionally needs +the tools its hook calls; a case is **skipped** (not failed) when a tool listed +in its `requires` file is missing from `PATH`. + +Current cases need: + +| Tool | Used by | +| --- | --- | +| `pre-commit`, `git` | the runner (always) | +| `terraform` | `terraform_fmt` | +| `hcledit` | `terraform_wrapper_module_for_each` | + +Install locally: + +```bash +# macOS (Homebrew) +brew install pre-commit terraform minamijoyo/hcledit/hcledit + +# Linux: pre-commit via pip, terraform via HashiCorp's repo, and hcledit: +curl -L "$(curl -s https://api.github.com/repos/minamijoyo/hcledit/releases/latest \ + | grep -o -E -m 1 "https://.+?_linux_amd64.tar.gz")" > hcledit.tar.gz \ + && tar -xzf hcledit.tar.gz hcledit && rm hcledit.tar.gz && sudo mv hcledit /usr/bin/ +``` + +See the repo [README "How to install"](../../README.md#how-to-install) for the +full tool list and other install methods. + +Or skip local installs entirely and use the project image, which bundles every +tool (this is what CI does) — see [Running](#running) below. + +## Running + +Natively: + +```bash +bash tests/e2e/run_e2e_tests.sh +``` + +Inside the project image (matches CI — bundles every tool, no local installs): + +```bash +docker build -t pct:e2e --build-arg INSTALL_ALL=true . +docker run --rm -v "$PWD:/lint" -w /lint --entrypoint bash pct:e2e \ + tests/e2e/run_e2e_tests.sh +``` + +## Adding a case + +1. Create `cases///`. +2. Add `input/` (the fixture) and a `.pre-commit-config.yaml` wiring the hook as + a `repo: local` hook with `entry: __PCT_REPO__/hooks/.sh`. +3. Generate `expected/`: run the hook once against a copy of `input/` and commit + the resulting tree. Set `expected_returncode` if the hook modifies files. +4. Add a `requires` file listing any non-trivial CLI tools the hook needs. + +The runner auto-discovers any `cases///` dir — no runner +changes are needed to cover a new hook. + +[issue-823]: https://github.com/antonbabenko/pre-commit-terraform/issues/823 From f9c96c42e73c19271362f186e0148cea1725ccd5 Mon Sep 17 00:00:00 2001 From: penpal Date: Fri, 29 May 2026 09:56:40 +0545 Subject: [PATCH 6/8] test: Address review feedback on the e2e runner Per review on #990: - replace the `RETURN` trap with explicit cleanup at the function's single exit (the `RETURN` trap is Bash-only and confused reviewers); - read the expected return code with `$(< file)` instead of `$(cat file)`; - quote the `true`/`false` flag values to avoid confusion with the builtins; - use `&>` instead of `> ... 2>&1`; - drop the redundant trailing slash on the copy destination; - print the summary with `printf` instead of a loop. No behavior change; the suite still passes natively and inside the image. Signed-off-by: penpal --- tests/e2e/run_e2e_tests.sh | 49 ++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/tests/e2e/run_e2e_tests.sh b/tests/e2e/run_e2e_tests.sh index 07b8ee389..33e69e45c 100755 --- a/tests/e2e/run_e2e_tests.sh +++ b/tests/e2e/run_e2e_tests.sh @@ -59,12 +59,10 @@ function run_case { work="$(mktemp -d)" config="$(mktemp)" log="$(mktemp)" - # shellcheck disable=SC2064 # expand paths now, on purpose - trap "rm -rf '$work' '$config' '$log' '$log.diff'" RETURN # Materialize the input working tree as a git repo (hooks and pre-commit # both expect one; the wrapper hook calls `git rev-parse --show-toplevel`). - cp -R "$case_dir/input/." "$work/" + cp -R "$case_dir/input/." "$work" git -C "$work" init -q git -C "$work" add -A git -C "$work" \ @@ -79,44 +77,46 @@ function run_case { ( cd "$work" pre-commit run --config "$config" --all-files - ) > "$log" 2>&1 || actual_rc=$? + ) &> "$log" || actual_rc=$? local expected_rc=0 [[ -f "$case_dir/expected_returncode" ]] && - expected_rc="$(cat "$case_dir/expected_returncode")" + expected_rc="$(< "$case_dir/expected_returncode")" # Drop the throwaway git dir so the tree compare only sees fixture output. # `git diff --no-index` is used instead of `diff -r` because the project # image ships BusyBox `diff`, which lacks `--exclude` and `-u`. rm -rf "$work/.git" - local ok=true reason='' + local ok="true" reason='' if [[ $actual_rc -ne $expected_rc ]]; then - ok=false + ok="false" reason="exit code ${actual_rc}, expected ${expected_rc}" elif ! git --no-pager diff --no-index --exit-code \ - "$case_dir/expected" "$work" > "$log.diff" 2>&1; then - ok=false + "$case_dir/expected" "$work" &> "$log.diff"; then + ok="false" reason='output differs from expected/' fi - if [[ $ok == true ]]; then + local rc=0 + if [[ $ok == "true" ]]; then summary+=("${C_GREEN}PASS${C_RESET} ${test_id}") passed+=1 - return 0 + else + summary+=("${C_RED}FAIL${C_RESET} ${test_id} (${reason})") + failed+=1 + echo "${C_RED}--- FAIL: ${test_id} (${reason}) ---${C_RESET}" + echo "pre-commit output:" + sed 's/^/ /' "$log" + if [[ -s "$log.diff" ]]; then + echo "diff (expected vs actual):" + sed 's/^/ /' "$log.diff" + fi + rc=1 fi - summary+=("${C_RED}FAIL${C_RESET} ${test_id} (${reason})") - failed+=1 - echo "${C_RED}--- FAIL: ${test_id} (${reason}) ---${C_RESET}" - echo "pre-commit output:" - sed 's/^/ /' "$log" - if [[ -s "$log.diff" ]]; then - echo "diff (expected vs actual):" - sed 's/^/ /' "$log.diff" - fi - rm -f "$log.diff" - return 1 + rm -rf "$work" "$config" "$log" "$log.diff" + return "$rc" } function main { @@ -132,10 +132,7 @@ function main { echo echo '==== e2e summary ====' - local line - for line in "${summary[@]}"; do - echo " $line" - done + printf ' %s\n' "${summary[@]}" echo " ${passed} passed, ${failed} failed, ${skipped} skipped" [[ $failed -eq 0 ]] From 2661af9e23c4c91b0e36444f718a769c3c2741f5 Mon Sep 17 00:00:00 2001 From: penpal Date: Fri, 29 May 2026 09:56:40 +0545 Subject: [PATCH 7/8] ci: Pin the e2e test image by digest Per review on #990, pin `ghcr.io/antonbabenko/pre-commit-terraform` by `sha256` digest instead of the floating `:latest` tag, so the bundled toolchain can't drift between runs. A Renovate annotation keeps the digest updated. Signed-off-by: penpal --- .github/workflows/e2e-tests.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index ddb0a57de..71ec6fc4b 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -27,9 +27,12 @@ jobs: # The published image bundles every tool the hooks need (terraform, # hcledit, pre-commit, git, ...). Hook and test code come from the checkout - # below, so the image tag only needs to provide tooling, not be in sync. + # below, so the image only needs to provide tooling, not be in sync. + # Pinned by digest for reproducibility/supply-chain; bump via Renovate. env: - IMAGE: ghcr.io/antonbabenko/pre-commit-terraform:latest + # renovate: datasource=docker depName=ghcr.io/antonbabenko/pre-commit-terraform + # yamllint disable-line rule:line-length + IMAGE: ghcr.io/antonbabenko/pre-commit-terraform:latest@sha256:4ef4b8323b27fc263535ad88c9d2f20488fcb3b520258e5e7f0553ed5f6692b5 steps: - name: Check out src from Git From a6052acfbb2a2a5c7c20bf2be22c539d49901069 Mon Sep 17 00:00:00 2001 From: penpal Date: Fri, 29 May 2026 10:29:04 +0545 Subject: [PATCH 8/8] test: Drop trailing slash from the e2e mismatch message Signed-off-by: penpal --- tests/e2e/run_e2e_tests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/run_e2e_tests.sh b/tests/e2e/run_e2e_tests.sh index 33e69e45c..060d06c3d 100755 --- a/tests/e2e/run_e2e_tests.sh +++ b/tests/e2e/run_e2e_tests.sh @@ -95,7 +95,7 @@ function run_case { elif ! git --no-pager diff --no-index --exit-code \ "$case_dir/expected" "$work" &> "$log.diff"; then ok="false" - reason='output differs from expected/' + reason='output differs from expected' fi local rc=0