diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00196c1..753d570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,47 @@ jobs: DEVRAIL_IMAGE: ${{ env.IMAGE_NAME }} DEVRAIL_TAG: ${{ env.IMAGE_TAG }} + # Story 15.1: Project-root discovery smoke test (issue #53) + # Verifies lib/project-discover.sh: single-root fallback (regression + # safety), Python+JS monorepo autodetection, multi-root, projects: + # override, and full make _lint/_test integration (cwd + local config + # + vite `@` alias resolution). + - name: Project-root discovery smoke test + run: bash tests/test-project-discover.sh + env: + DEVRAIL_IMAGE: ${{ env.IMAGE_NAME }} + DEVRAIL_TAG: ${{ env.IMAGE_TAG }} + + # Story 15.2: Dependency-install-before-test smoke test (issue #52) + # Verifies lib/dependency-install.sh: uv.lock/requirements.txt/ + # pyproject.toml (Python) and package-lock.json (JS) autodetection + # with REAL network installs, test.install/test.setup overrides, a + # failed install failing make test fast (no partial-install test + # runs), and the no-manifest regression case. Requires network + # egress to PyPI/npm — this runner has it. + - name: Dependency install smoke test + run: bash tests/test-dependency-install.sh + env: + DEVRAIL_IMAGE: ${{ env.IMAGE_NAME }} + DEVRAIL_TAG: ${{ env.IMAGE_TAG }} + + # Story 15.4: Ephemeral test.services smoke test (issue #52) + # Verifies scripts/test-services.sh: postgres/redis alone and + # together (real queries through the injected DATABASE_URL/ + # REDIS_URL), no-services regression, docker_network mutual + # exclusion, unsupported-entry error, and — critically — that + # teardown actually happens, including after a mid-flight SIGKILL + # (checked via `docker ps`/`docker network ls`, not just by reading + # the trap code). Runs `make test` directly on the runner (this is + # host-side orchestration, not something a bind-mounted container + # invocation can exercise), so it needs Docker-in-Docker — already + # available here since every other step in this job uses it. + - name: Test services smoke test + run: bash tests/test-test-services.sh + env: + DEVRAIL_IMAGE: ${{ env.IMAGE_NAME }} + DEVRAIL_TAG: ${{ env.IMAGE_TAG }} + # Phase 2e: Plugin resolver + lockfile smoke test (Story 13.3) # Drives `make plugins-update` against a local-filesystem git fixture # (no network) covering: SHA passthrough, tag→SHA, branch rejection, diff --git a/CHANGELOG.md b/CHANGELOG.md index 909d5f7..9ebb42c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Issue #53 (Story 15.1):** `make lint`/`format`/`fix`/`test`/`security` + now autodetect per-language project roots in Python+JS monorepos (e.g. + `api/pyproject.toml` + `frontend/package.json`, no manifests at repo + root) and run each language's tools with cwd set there, so local config + (`tsconfig.json`, `vite.config.ts` path aliases, `pyproject.toml`) + resolves correctly. New `lib/project-discover.sh` helper; optional + `.devrail.yml` `projects:` override for layouts autodetection can't + infer. Single-root projects (the common case) are unaffected — output + is byte-identical to previous versions. +- **Issue #52 (Story 15.2):** `make test` now installs a project's own + dependencies before running `pytest`/`vitest`, so tests no longer fail + at import time with `ModuleNotFoundError`/unresolved-import errors. + Autodetects `uv.lock` (`uv export | uv pip install --system`), + `requirements*.txt` (`pip install -r`), or `pyproject.toml`/`setup.py` + (`pip install -e .`) for Python, and `package-lock.json` (`npm ci`) for + JS/TS, in each project root discovered by Story 15.1. New + `lib/dependency-install.sh` helper; new `.devrail.yml` `test.install`/ + `test.setup` overrides. Container image now ships `uv`. A failed install + fails `make test` immediately — the test suite never runs against a + broken install. Projects with no lockfile/manifest are unaffected. +- **Issue #53 (Story 15.3):** extends Story 15.1's project-root discovery + to Go (`go.mod`) and Rust (`Cargo.toml`) — `go test`, `golangci-lint`, + `cargo test`, `cargo clippy`, and `cargo fmt` previously failed outright + ("directory prefix . does not contain main module", "could not find + Cargo.toml") for a monorepo module not rooted at the repo root; they + now run with cwd set to the discovered module root, same as Python/JS. + Ansible needed no equivalent change — `ansible-lint` already discovers + playbooks recursively regardless of cwd. +- **Issue #52 (Story 15.4):** `.devrail.yml` `test.services` starts + throwaway `postgres:`/`redis:` containers before `make test` + and tears them down afterward (pass or fail), injecting `DATABASE_URL`/ + `REDIS_URL`. Orchestration is entirely host-side — the toolchain + container has no `docker` CLI or socket access, deliberately — via a + new `_test-services-up` host prerequisite (mirroring `_extended-image`'s + pattern) feeding into the existing `docker_network`/`env` plumbing + (issue #48). `test.services` and `docker_network` are mutually + exclusive. A `SIGKILL`'d run can leave orphaned containers/network + behind (a shell trap can't catch `SIGKILL`); the next `make test` run + detects and cleans up stale state automatically. Projects with no + `test.services` declared are unaffected. + ## [1.12.0] - 2026-05-30 ### Added diff --git a/Makefile b/Makefile index b62b87b..9aad556 100644 --- a/Makefile +++ b/Makefile @@ -76,6 +76,25 @@ DEVRAIL_DOCKER_NETWORK := $(shell yq -r '.docker_network // ""' $(DEVRAIL_CONFIG DEVRAIL_NETWORK_FLAG := $(if $(DEVRAIL_DOCKER_NETWORK),--network $(DEVRAIL_DOCKER_NETWORK),) DEVRAIL_VOLUME_FLAGS := $(shell yq -r '.docker_volumes // [] | .[] | "-v " + .' $(DEVRAIL_CONFIG) 2>/dev/null) +# HAS_TEST_SERVICES_DECLARED — set when .devrail.yml has a non-empty +# `test.services` list. Mirrors HAS_PLUGINS_DECLARED above: guards +# `_test-services-host-bin`'s extraction AND `_test-services-up`'s +# invocation so a consumer repo that never declares test.services (the +# common case) pays neither the docker create/cp/rm extraction cost nor +# risks invoking a script that was never extracted. +DEVRAIL_TEST_SERVICES_PROBE := $(shell yq -r '.test.services // [] | length' $(DEVRAIL_CONFIG) 2>/dev/null) +HAS_TEST_SERVICES_DECLARED := $(if $(filter-out 0,$(DEVRAIL_TEST_SERVICES_PROBE)),yes,) + +# Story 15.4: test.services ephemeral containers. Recursively-expanded (=, +# not :=) so these re-evaluate on every DOCKER_RUN expansion — picking up +# the network/env-file that `_test-services-up` (a host-side prerequisite +# of `test:` only) writes under .devrail/test-services/, mirroring the +# DEVRAIL_RESOLVED_IMAGE / _extended-image pattern. A true no-op for every +# target except `test`: nothing else depends on _test-services-up, so +# these files never exist for lint/format/fix/security/scan/docs/etc. +DEVRAIL_TEST_SERVICES_NETWORK_FLAG = $(if $(wildcard .devrail/test-services/network),--network $(shell cat .devrail/test-services/network),) +DEVRAIL_TEST_SERVICES_ENV_FLAG = $(if $(wildcard .devrail/test-services/env),--env-file .devrail/test-services/env,) + # Ruby lint/format scope. Defaults to the conventional Rails directory set so # rubocop and reek do not descend into vendor/bundle/ (which can hold tens of # thousands of files of installed gem source). Override per-project via: @@ -129,6 +148,8 @@ DOCKER_RUN = docker run --rm \ $(RUBY_DOCKER_ENV) \ $(DEVRAIL_NETWORK_FLAG) \ $(DEVRAIL_VOLUME_FLAGS) \ + $(DEVRAIL_TEST_SERVICES_NETWORK_FLAG) \ + $(DEVRAIL_TEST_SERVICES_ENV_FLAG) \ $(DEVRAIL_RESOLVED_IMAGE) .DEFAULT_GOAL := help @@ -137,7 +158,7 @@ DOCKER_RUN = docker run --rm \ # .PHONY declarations # --------------------------------------------------------------------------- .PHONY: help build lint format fix test security scan docs changelog check install-hooks init release plugins-update -.PHONY: _lint _format _fix _test _security _scan _docs _changelog _check _check-config _init _plugins-update _plugins-verify _ensure-host-cache _generate-dockerfile _extended-image _devrail-host-bin +.PHONY: _lint _format _fix _test _security _scan _docs _changelog _check _check-config _init _plugins-update _plugins-verify _ensure-host-cache _generate-dockerfile _extended-image _devrail-host-bin _test-services-up # =========================================================================== # Public targets (run on host, delegate to Docker container) @@ -209,6 +230,57 @@ _extended-image: _ensure-host-cache _devrail-host-bin rm -f .devrail/extended-image-tag; \ fi +# --- _test-services-host-bin: extract test-services.sh + lib/log.sh from container --- +# Story 15.4: consumer template repos inherit this Makefile but not +# scripts/, mirroring _devrail-host-bin's pattern exactly (same cache +# file, same docker create/cp/rm shape, same HAS_*_DECLARED upfront guard +# so a repo that never declares test.services — the common case — pays +# no docker create/cp/rm cost at all, not even once per image tag). +# When the dev-toolchain repo itself runs (scripts/ present locally) we +# use the on-disk copy so changes take effect without a rebuild. +# Otherwise extract from the resolved core image to .devrail/host-bin/. +_test-services-host-bin: + @if [ -z "$(HAS_TEST_SERVICES_DECLARED)" ]; then \ + exit 0; \ + fi; \ + if [ -f scripts/test-services.sh ]; then \ + exit 0; \ + fi; \ + expected="$(DEVRAIL_IMAGE):$(DEVRAIL_TAG)"; \ + cached=$$(cat .devrail/host-bin/.image-tag 2>/dev/null || true); \ + if [ "$$cached" = "$$expected" ] && \ + [ -f .devrail/host-bin/scripts/test-services.sh ] && \ + [ -f .devrail/host-bin/lib/log.sh ]; then \ + exit 0; \ + fi; \ + mkdir -p .devrail/host-bin/scripts .devrail/host-bin/lib; \ + echo '{"level":"info","msg":"extracting test-services orchestrator from container","image":"'"$$expected"'","language":"_test-services"}' >&2; \ + cid=$$(docker create "$$expected" /bin/true) || { \ + echo '{"level":"error","msg":"docker create failed for host-bin extraction","image":"'"$$expected"'","language":"_test-services"}' >&2; \ + exit 2; \ + }; \ + trap 'docker rm "$$cid" >/dev/null 2>&1 || true' EXIT; \ + docker cp "$$cid":/opt/devrail/scripts/test-services.sh .devrail/host-bin/scripts/test-services.sh && \ + docker cp "$$cid":/opt/devrail/lib/log.sh .devrail/host-bin/lib/log.sh && \ + chmod +x .devrail/host-bin/scripts/test-services.sh && \ + printf '%s\n' "$$expected" > .devrail/host-bin/.image-tag + +# --- _test-services-up: start declared test.services before `make test` --- +# Story 15.4: HOST-side target, `test:`-only prerequisite. No-op (fast — +# a single yq read) when .devrail.yml has no `test.services`. When +# services ARE declared, starts them and writes network/env state under +# .devrail/test-services/ for DEVRAIL_TEST_SERVICES_NETWORK_FLAG/ +# DEVRAIL_TEST_SERVICES_ENV_FLAG to pick up. +_test-services-up: _ensure-host-cache _test-services-host-bin + @if [ -f scripts/test-services.sh ]; then \ + bash scripts/test-services.sh up; \ + elif [ -z "$(HAS_TEST_SERVICES_DECLARED)" ]; then \ + exit 0; \ + else \ + DEVRAIL_LIB="$$(pwd)/.devrail/host-bin/lib" \ + bash .devrail/host-bin/scripts/test-services.sh up; \ + fi + help: ## Show this help @echo "DevRail dev-toolchain — container image build and validation" @echo "" @@ -277,7 +349,14 @@ scan: _ensure-host-cache _extended-image ## Run universal scanners (trivy, gitle security: _ensure-host-cache _extended-image ## Run language-specific security scanners $(DOCKER_RUN) make _security -test: _ensure-host-cache _extended-image ## Run validation tests +test: _ensure-host-cache _extended-image _test-services-up ## Run validation tests + @trap '\ + if [ -f scripts/test-services.sh ]; then \ + bash scripts/test-services.sh down; \ + elif [ -f .devrail/host-bin/scripts/test-services.sh ]; then \ + DEVRAIL_LIB="$$(pwd)/.devrail/host-bin/lib" bash .devrail/host-bin/scripts/test-services.sh down; \ + fi \ + ' EXIT; \ $(DOCKER_RUN) make _test # =========================================================================== @@ -396,19 +475,23 @@ _plugins-load: _plugins-verify # call to keep behaviour symmetric with the per-language blocks. _lint: _plugins-load @. /opt/devrail/lib/plugin-execute.sh; \ + . "$${DEVRAIL_LIB:-/opt/devrail/lib}/project-discover.sh"; \ start_time=$$(date +%s%3N); \ overall_exit=0; \ ran_languages=""; \ failed_languages=""; \ if [ -n "$(HAS_PYTHON)" ]; then \ - ran_languages="$${ran_languages}\"python\","; \ - ruff check . || { overall_exit=1; failed_languages="$${failed_languages}\"python\","; }; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r py_root; do \ + if [ "$$py_root" = "." ]; then py_tag="python"; else py_tag="python:$$py_root"; fi; \ + ran_languages="$${ran_languages}\"$$py_tag\","; \ + (cd "$$py_root" && ruff check .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag\","; }; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots python); \ fi; \ if [ -n "$(HAS_BASH)" ]; then \ ran_languages="$${ran_languages}\"bash\","; \ @@ -496,60 +579,69 @@ _lint: _plugins-load fi; \ fi; \ if [ -n "$(HAS_GO)" ]; then \ - ran_languages="$${ran_languages}\"go\","; \ - go_files=$$(find . -name '*.go' -not -path './.git/*' -not -path './vendor/*' -not -path './node_modules/*' 2>/dev/null); \ - if [ -n "$$go_files" ]; then \ - golangci-lint run ./... || { overall_exit=1; failed_languages="$${failed_languages}\"go\","; }; \ - else \ - echo '{"level":"info","msg":"skipping go lint: no .go files found","language":"go"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r go_root; do \ + if [ "$$go_root" = "." ]; then go_tag="go"; else go_tag="go:$$go_root"; fi; \ + go_files=$$(find "$$go_root" -name '*.go' -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' 2>/dev/null); \ + if [ -n "$$go_files" ]; then \ + ran_languages="$${ran_languages}\"$$go_tag\","; \ + (cd "$$go_root" && golangci-lint run ./...) || { overall_exit=1; failed_languages="$${failed_languages}\"$$go_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping go lint: no .go files found\",\"language\":\"go\",\"root\":\"$$go_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots go); \ fi; \ if [ -n "$(HAS_JAVASCRIPT)" ]; then \ - ran_languages="$${ran_languages}\"javascript\","; \ - js_files=$$(find . \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' -o -name '*.mjs' -o -name '*.cjs' \) -not -path './.git/*' -not -path './vendor/*' -not -path './node_modules/*' -not -path './dist/*' -not -path './build/*' 2>/dev/null); \ - if [ -n "$$js_files" ]; then \ - eslint . || { overall_exit=1; failed_languages="$${failed_languages}\"javascript:eslint\","; }; \ - else \ - echo '{"level":"info","msg":"skipping javascript eslint lint: no JS/TS files found","language":"javascript"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ - if [ -f "tsconfig.json" ]; then \ - tsc --noEmit || { overall_exit=1; failed_languages="$${failed_languages}\"javascript:tsc\","; }; \ - else \ - echo '{"level":"info","msg":"skipping tsc type check: no tsconfig.json found","language":"javascript"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r js_root; do \ + if [ "$$js_root" = "." ]; then js_tag="javascript"; else js_tag="javascript:$$js_root"; fi; \ + ran_languages="$${ran_languages}\"$$js_tag\","; \ + js_files=$$(find "$$js_root" \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' -o -name '*.mjs' -o -name '*.cjs' \) -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' -not -path '*/dist/*' -not -path '*/build/*' 2>/dev/null); \ + if [ -n "$$js_files" ]; then \ + (cd "$$js_root" && eslint .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag:eslint\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping javascript eslint lint: no JS/TS files found\",\"language\":\"javascript\",\"root\":\"$$js_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + if [ -f "$$js_root/tsconfig.json" ]; then \ + (cd "$$js_root" && tsc --noEmit) || { overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag:tsc\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping tsc type check: no tsconfig.json found\",\"language\":\"javascript\",\"root\":\"$$js_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots javascript); \ fi; \ if [ -n "$(HAS_RUST)" ]; then \ - ran_languages="$${ran_languages}\"rust\","; \ - rs_files=$$(find . -name '*.rs' -not -path './.git/*' -not -path './vendor/*' -not -path './target/*' 2>/dev/null); \ - if [ -n "$$rs_files" ]; then \ - cargo clippy --all-targets --all-features -- -D warnings || { overall_exit=1; failed_languages="$${failed_languages}\"rust\","; }; \ - else \ - echo '{"level":"info","msg":"skipping rust lint: no .rs files found","language":"rust"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r rust_root; do \ + if [ "$$rust_root" = "." ]; then rust_tag="rust"; else rust_tag="rust:$$rust_root"; fi; \ + rs_files=$$(find "$$rust_root" -name '*.rs' -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/target/*' 2>/dev/null); \ + if [ -n "$$rs_files" ]; then \ + ran_languages="$${ran_languages}\"$$rust_tag\","; \ + (cd "$$rust_root" && cargo clippy --all-targets --all-features -- -D warnings) || { overall_exit=1; failed_languages="$${failed_languages}\"$$rust_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping rust lint: no .rs files found\",\"language\":\"rust\",\"root\":\"$$rust_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots rust); \ fi; \ if [ -n "$(HAS_SWIFT)" ]; then \ ran_languages="$${ran_languages}\"swift\","; \ @@ -621,19 +713,23 @@ _lint: _plugins-load # --- _format: language-specific format checking --- _format: _plugins-load @. /opt/devrail/lib/plugin-execute.sh; \ + . "$${DEVRAIL_LIB:-/opt/devrail/lib}/project-discover.sh"; \ start_time=$$(date +%s%3N); \ overall_exit=0; \ ran_languages=""; \ failed_languages=""; \ if [ -n "$(HAS_PYTHON)" ]; then \ - ran_languages="$${ran_languages}\"python\","; \ - ruff format --check . || { overall_exit=1; failed_languages="$${failed_languages}\"python\","; }; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r py_root; do \ + if [ "$$py_root" = "." ]; then py_tag="python"; else py_tag="python:$$py_root"; fi; \ + ran_languages="$${ran_languages}\"$$py_tag\","; \ + (cd "$$py_root" && ruff format --check .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag\","; }; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots python); \ fi; \ if [ -n "$(HAS_BASH)" ]; then \ ran_languages="$${ran_languages}\"bash\","; \ @@ -686,49 +782,58 @@ _format: _plugins-load fi; \ fi; \ if [ -n "$(HAS_GO)" ]; then \ - ran_languages="$${ran_languages}\"go\","; \ - go_files=$$(find . -name '*.go' -not -path './.git/*' -not -path './vendor/*' -not -path './node_modules/*' 2>/dev/null); \ - if [ -n "$$go_files" ]; then \ - gofumpt -d . || { overall_exit=1; failed_languages="$${failed_languages}\"go\","; }; \ - else \ - echo '{"level":"info","msg":"skipping go format: no .go files found","language":"go"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r go_root; do \ + if [ "$$go_root" = "." ]; then go_tag="go"; else go_tag="go:$$go_root"; fi; \ + go_files=$$(find "$$go_root" -name '*.go' -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' 2>/dev/null); \ + if [ -n "$$go_files" ]; then \ + ran_languages="$${ran_languages}\"$$go_tag\","; \ + (cd "$$go_root" && gofumpt -d .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$go_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping go format: no .go files found\",\"language\":\"go\",\"root\":\"$$go_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots go); \ fi; \ if [ -n "$(HAS_JAVASCRIPT)" ]; then \ - ran_languages="$${ran_languages}\"javascript\","; \ - js_files=$$(find . \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' -o -name '*.mjs' -o -name '*.cjs' \) -not -path './.git/*' -not -path './vendor/*' -not -path './node_modules/*' -not -path './dist/*' -not -path './build/*' 2>/dev/null); \ - if [ -n "$$js_files" ]; then \ - prettier --check . || { overall_exit=1; failed_languages="$${failed_languages}\"javascript\","; }; \ - else \ - echo '{"level":"info","msg":"skipping javascript format: no JS/TS files found","language":"javascript"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r js_root; do \ + if [ "$$js_root" = "." ]; then js_tag="javascript"; else js_tag="javascript:$$js_root"; fi; \ + ran_languages="$${ran_languages}\"$$js_tag\","; \ + js_files=$$(find "$$js_root" \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' -o -name '*.mjs' -o -name '*.cjs' \) -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' -not -path '*/dist/*' -not -path '*/build/*' 2>/dev/null); \ + if [ -n "$$js_files" ]; then \ + (cd "$$js_root" && prettier --check .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping javascript format: no JS/TS files found\",\"language\":\"javascript\",\"root\":\"$$js_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots javascript); \ fi; \ if [ -n "$(HAS_RUST)" ]; then \ - ran_languages="$${ran_languages}\"rust\","; \ - rs_files=$$(find . -name '*.rs' -not -path './.git/*' -not -path './vendor/*' -not -path './target/*' 2>/dev/null); \ - if [ -n "$$rs_files" ]; then \ - cargo fmt --all -- --check || { overall_exit=1; failed_languages="$${failed_languages}\"rust\","; }; \ - else \ - echo '{"level":"info","msg":"skipping rust format: no .rs files found","language":"rust"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r rust_root; do \ + if [ "$$rust_root" = "." ]; then rust_tag="rust"; else rust_tag="rust:$$rust_root"; fi; \ + rs_files=$$(find "$$rust_root" -name '*.rs' -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/target/*' 2>/dev/null); \ + if [ -n "$$rs_files" ]; then \ + ran_languages="$${ran_languages}\"$$rust_tag\","; \ + (cd "$$rust_root" && cargo fmt --all -- --check) || { overall_exit=1; failed_languages="$${failed_languages}\"$$rust_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping rust format: no .rs files found\",\"language\":\"rust\",\"root\":\"$$rust_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots rust); \ fi; \ if [ -n "$(HAS_SWIFT)" ]; then \ ran_languages="$${ran_languages}\"swift\","; \ @@ -779,19 +884,23 @@ _format: _plugins-load # --- _fix: language-specific format fixing (in-place) --- _fix: _plugins-load @. /opt/devrail/lib/plugin-execute.sh; \ + . "$${DEVRAIL_LIB:-/opt/devrail/lib}/project-discover.sh"; \ start_time=$$(date +%s%3N); \ overall_exit=0; \ ran_languages=""; \ failed_languages=""; \ if [ -n "$(HAS_PYTHON)" ]; then \ - ran_languages="$${ran_languages}\"python\","; \ - ruff format . || { overall_exit=1; failed_languages="$${failed_languages}\"python\","; }; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r py_root; do \ + if [ "$$py_root" = "." ]; then py_tag="python"; else py_tag="python:$$py_root"; fi; \ + ran_languages="$${ran_languages}\"$$py_tag\","; \ + (cd "$$py_root" && ruff format .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag\","; }; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots python); \ fi; \ if [ -n "$(HAS_BASH)" ]; then \ ran_languages="$${ran_languages}\"bash\","; \ @@ -844,49 +953,58 @@ _fix: _plugins-load fi; \ fi; \ if [ -n "$(HAS_GO)" ]; then \ - ran_languages="$${ran_languages}\"go\","; \ - go_files=$$(find . -name '*.go' -not -path './.git/*' -not -path './vendor/*' -not -path './node_modules/*' 2>/dev/null); \ - if [ -n "$$go_files" ]; then \ - gofumpt -w . || { overall_exit=1; failed_languages="$${failed_languages}\"go\","; }; \ - else \ - echo '{"level":"info","msg":"skipping go fix: no .go files found","language":"go"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r go_root; do \ + if [ "$$go_root" = "." ]; then go_tag="go"; else go_tag="go:$$go_root"; fi; \ + go_files=$$(find "$$go_root" -name '*.go' -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' 2>/dev/null); \ + if [ -n "$$go_files" ]; then \ + ran_languages="$${ran_languages}\"$$go_tag\","; \ + (cd "$$go_root" && gofumpt -w .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$go_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping go fix: no .go files found\",\"language\":\"go\",\"root\":\"$$go_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots go); \ fi; \ if [ -n "$(HAS_JAVASCRIPT)" ]; then \ - ran_languages="$${ran_languages}\"javascript\","; \ - js_files=$$(find . \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' -o -name '*.mjs' -o -name '*.cjs' \) -not -path './.git/*' -not -path './vendor/*' -not -path './node_modules/*' -not -path './dist/*' -not -path './build/*' 2>/dev/null); \ - if [ -n "$$js_files" ]; then \ - prettier --write . || { overall_exit=1; failed_languages="$${failed_languages}\"javascript\","; }; \ - else \ - echo '{"level":"info","msg":"skipping javascript fix: no JS/TS files found","language":"javascript"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r js_root; do \ + if [ "$$js_root" = "." ]; then js_tag="javascript"; else js_tag="javascript:$$js_root"; fi; \ + ran_languages="$${ran_languages}\"$$js_tag\","; \ + js_files=$$(find "$$js_root" \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' -o -name '*.mjs' -o -name '*.cjs' \) -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' -not -path '*/dist/*' -not -path '*/build/*' 2>/dev/null); \ + if [ -n "$$js_files" ]; then \ + (cd "$$js_root" && prettier --write .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping javascript fix: no JS/TS files found\",\"language\":\"javascript\",\"root\":\"$$js_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots javascript); \ fi; \ if [ -n "$(HAS_RUST)" ]; then \ - ran_languages="$${ran_languages}\"rust\","; \ - rs_files=$$(find . -name '*.rs' -not -path './.git/*' -not -path './vendor/*' -not -path './target/*' 2>/dev/null); \ - if [ -n "$$rs_files" ]; then \ - cargo fmt --all || { overall_exit=1; failed_languages="$${failed_languages}\"rust\","; }; \ - else \ - echo '{"level":"info","msg":"skipping rust fix: no .rs files found","language":"rust"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r rust_root; do \ + if [ "$$rust_root" = "." ]; then rust_tag="rust"; else rust_tag="rust:$$rust_root"; fi; \ + rs_files=$$(find "$$rust_root" -name '*.rs' -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/target/*' 2>/dev/null); \ + if [ -n "$$rs_files" ]; then \ + ran_languages="$${ran_languages}\"$$rust_tag\","; \ + (cd "$$rust_root" && cargo fmt --all) || { overall_exit=1; failed_languages="$${failed_languages}\"$$rust_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping rust fix: no .rs files found\",\"language\":\"rust\",\"root\":\"$$rust_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots rust); \ fi; \ if [ -n "$(HAS_SWIFT)" ]; then \ ran_languages="$${ran_languages}\"swift\","; \ @@ -944,25 +1062,34 @@ _fix: _plugins-load # --- _test: language-specific test runners --- _test: _plugins-load @. /opt/devrail/lib/plugin-execute.sh; \ + . "$${DEVRAIL_LIB:-/opt/devrail/lib}/project-discover.sh"; \ + . "$${DEVRAIL_LIB:-/opt/devrail/lib}/dependency-install.sh"; \ start_time=$$(date +%s%3N); \ overall_exit=0; \ ran_languages=""; \ failed_languages=""; \ skipped_languages=""; \ if [ -n "$(HAS_PYTHON)" ]; then \ - if [ -d "tests" ] || find . -name '*_test.py' -o -name 'test_*.py' 2>/dev/null | grep -q .; then \ - ran_languages="$${ran_languages}\"python\","; \ - pytest || { overall_exit=1; failed_languages="$${failed_languages}\"python\","; }; \ - else \ - skipped_languages="$${skipped_languages}\"python\","; \ - echo '{"level":"info","msg":"skipping python tests: no test files found","language":"python"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r py_root; do \ + if [ "$$py_root" = "." ]; then py_tag="python"; else py_tag="python:$$py_root"; fi; \ + if ! install_project_deps python "$$py_root"; then \ + overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag:install\","; \ + elif ! run_project_setup "$$py_root"; then \ + overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag:setup\","; \ + elif [ -d "$$py_root/tests" ] || find "$$py_root" -name '*_test.py' -o -name 'test_*.py' 2>/dev/null | grep -q .; then \ + ran_languages="$${ran_languages}\"$$py_tag\","; \ + (cd "$$py_root" && pytest) || { overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag\","; }; \ + else \ + skipped_languages="$${skipped_languages}\"$$py_tag\","; \ + echo "{\"level\":\"info\",\"msg\":\"skipping python tests: no test files found\",\"language\":\"python\",\"root\":\"$$py_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots python); \ fi; \ if [ -n "$(HAS_BASH)" ]; then \ if find . -name '*.bats' -not -path './.git/*' 2>/dev/null | grep -q .; then \ @@ -1036,50 +1163,63 @@ _test: _plugins-load fi; \ fi; \ if [ -n "$(HAS_GO)" ]; then \ - if find . -name '*_test.go' -not -path './.git/*' -not -path './vendor/*' 2>/dev/null | grep -q .; then \ - ran_languages="$${ran_languages}\"go\","; \ - go test ./... || { overall_exit=1; failed_languages="$${failed_languages}\"go\","; }; \ - else \ - skipped_languages="$${skipped_languages}\"go\","; \ - echo '{"level":"info","msg":"skipping go tests: no *_test.go files found","language":"go"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r go_root; do \ + if [ "$$go_root" = "." ]; then go_tag="go"; else go_tag="go:$$go_root"; fi; \ + if find "$$go_root" -name '*_test.go' -not -path '*/.git/*' -not -path '*/vendor/*' 2>/dev/null | grep -q .; then \ + ran_languages="$${ran_languages}\"$$go_tag\","; \ + (cd "$$go_root" && go test ./...) || { overall_exit=1; failed_languages="$${failed_languages}\"$$go_tag\","; }; \ + else \ + skipped_languages="$${skipped_languages}\"$$go_tag\","; \ + echo "{\"level\":\"info\",\"msg\":\"skipping go tests: no *_test.go files found\",\"language\":\"go\",\"root\":\"$$go_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots go); \ fi; \ if [ -n "$(HAS_JAVASCRIPT)" ]; then \ - if find . \( -name '*.test.*' -o -name '*.spec.*' \) -not -path './.git/*' -not -path './vendor/*' -not -path './node_modules/*' -not -path './dist/*' -not -path './build/*' 2>/dev/null | grep -q .; then \ - ran_languages="$${ran_languages}\"javascript\","; \ - vitest run || { overall_exit=1; failed_languages="$${failed_languages}\"javascript\","; }; \ - else \ - skipped_languages="$${skipped_languages}\"javascript\","; \ - echo '{"level":"info","msg":"skipping javascript tests: no *.test.* or *.spec.* files found","language":"javascript"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r js_root; do \ + if [ "$$js_root" = "." ]; then js_tag="javascript"; else js_tag="javascript:$$js_root"; fi; \ + if ! install_project_deps javascript "$$js_root"; then \ + overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag:install\","; \ + elif ! run_project_setup "$$js_root"; then \ + overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag:setup\","; \ + elif find "$$js_root" \( -name '*.test.*' -o -name '*.spec.*' \) -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' -not -path '*/dist/*' -not -path '*/build/*' 2>/dev/null | grep -q .; then \ + ran_languages="$${ran_languages}\"$$js_tag\","; \ + (cd "$$js_root" && vitest run) || { overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag\","; }; \ + else \ + skipped_languages="$${skipped_languages}\"$$js_tag\","; \ + echo "{\"level\":\"info\",\"msg\":\"skipping javascript tests: no *.test.* or *.spec.* files found\",\"language\":\"javascript\",\"root\":\"$$js_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots javascript); \ fi; \ if [ -n "$(HAS_RUST)" ]; then \ - rs_files=$$(find . -name '*.rs' -not -path './.git/*' -not -path './vendor/*' -not -path './target/*' 2>/dev/null); \ - if [ -n "$$rs_files" ] && [ -f "Cargo.toml" ]; then \ - ran_languages="$${ran_languages}\"rust\","; \ - cargo test --all-targets || { overall_exit=1; failed_languages="$${failed_languages}\"rust\","; }; \ - else \ - skipped_languages="$${skipped_languages}\"rust\","; \ - echo '{"level":"info","msg":"skipping rust tests: no .rs files or Cargo.toml found","language":"rust"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r rust_root; do \ + if [ "$$rust_root" = "." ]; then rust_tag="rust"; else rust_tag="rust:$$rust_root"; fi; \ + rs_files=$$(find "$$rust_root" -name '*.rs' -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/target/*' 2>/dev/null); \ + if [ -n "$$rs_files" ] && [ -f "$$rust_root/Cargo.toml" ]; then \ + ran_languages="$${ran_languages}\"$$rust_tag\","; \ + (cd "$$rust_root" && cargo test --all-targets) || { overall_exit=1; failed_languages="$${failed_languages}\"$$rust_tag\","; }; \ + else \ + skipped_languages="$${skipped_languages}\"$$rust_tag\","; \ + echo "{\"level\":\"info\",\"msg\":\"skipping rust tests: no .rs files or Cargo.toml found\",\"language\":\"rust\",\"root\":\"$$rust_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots rust); \ fi; \ if [ -n "$(HAS_SWIFT)" ]; then \ swift_files=$$(find . -name '*.swift' -not -path './.git/*' -not -path './.build/*' -not -path './DerivedData/*' 2>/dev/null); \ @@ -1133,27 +1273,31 @@ _test: _plugins-load # --- _security: language-specific security scanners --- _security: _plugins-load @. /opt/devrail/lib/plugin-execute.sh; \ + . "$${DEVRAIL_LIB:-/opt/devrail/lib}/project-discover.sh"; \ start_time=$$(date +%s%3N); \ overall_exit=0; \ ran_languages=""; \ failed_languages=""; \ skipped_languages=""; \ if [ -n "$(HAS_PYTHON)" ]; then \ - ran_languages="$${ran_languages}\"python\","; \ - bandit -r . -q || { overall_exit=1; failed_languages="$${failed_languages}\"python:bandit\","; }; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ - semgrep --config auto . --quiet 2>/dev/null || { overall_exit=1; failed_languages="$${failed_languages}\"python:semgrep\","; }; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r py_root; do \ + if [ "$$py_root" = "." ]; then py_tag="python"; else py_tag="python:$$py_root"; fi; \ + ran_languages="$${ran_languages}\"$$py_tag\","; \ + (cd "$$py_root" && bandit -r . -q) || { overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag:bandit\","; }; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + (cd "$$py_root" && semgrep --config auto . --quiet 2>/dev/null) || { overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag:semgrep\","; }; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots python); \ fi; \ if [ -n "$(HAS_BASH)" ]; then \ skipped_languages="$${skipped_languages}\"bash\","; \ @@ -1206,60 +1350,69 @@ _security: _plugins-load fi; \ fi; \ if [ -n "$(HAS_GO)" ]; then \ - if [ -f "go.sum" ]; then \ - ran_languages="$${ran_languages}\"go\","; \ - govulncheck ./... || { overall_exit=1; failed_languages="$${failed_languages}\"go:govulncheck\","; }; \ - else \ - skipped_languages="$${skipped_languages}\"go\","; \ - echo '{"level":"info","msg":"skipping govulncheck: no go.sum found","language":"go"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r go_root; do \ + if [ "$$go_root" = "." ]; then go_tag="go"; else go_tag="go:$$go_root"; fi; \ + if [ -f "$$go_root/go.sum" ]; then \ + ran_languages="$${ran_languages}\"$$go_tag\","; \ + (cd "$$go_root" && govulncheck ./...) || { overall_exit=1; failed_languages="$${failed_languages}\"$$go_tag:govulncheck\","; }; \ + else \ + skipped_languages="$${skipped_languages}\"$$go_tag\","; \ + echo "{\"level\":\"info\",\"msg\":\"skipping govulncheck: no go.sum found\",\"language\":\"go\",\"root\":\"$$go_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots go); \ fi; \ if [ -n "$(HAS_JAVASCRIPT)" ]; then \ - if [ -f "package-lock.json" ]; then \ - ran_languages="$${ran_languages}\"javascript\","; \ - npm audit --audit-level=moderate || { overall_exit=1; failed_languages="$${failed_languages}\"javascript:npm-audit\","; }; \ - else \ - skipped_languages="$${skipped_languages}\"javascript\","; \ - echo '{"level":"info","msg":"skipping npm audit: no package-lock.json found","language":"javascript"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r js_root; do \ + if [ "$$js_root" = "." ]; then js_tag="javascript"; else js_tag="javascript:$$js_root"; fi; \ + if [ -f "$$js_root/package-lock.json" ]; then \ + ran_languages="$${ran_languages}\"$$js_tag\","; \ + (cd "$$js_root" && npm audit --audit-level=moderate) || { overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag:npm-audit\","; }; \ + else \ + skipped_languages="$${skipped_languages}\"$$js_tag\","; \ + echo "{\"level\":\"info\",\"msg\":\"skipping npm audit: no package-lock.json found\",\"language\":\"javascript\",\"root\":\"$$js_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots javascript); \ fi; \ if [ -n "$(HAS_RUST)" ]; then \ - if [ -f "Cargo.lock" ]; then \ - ran_languages="$${ran_languages}\"rust\","; \ - cargo audit || { overall_exit=1; failed_languages="$${failed_languages}\"rust:cargo-audit\","; }; \ - else \ - skipped_languages="$${skipped_languages}\"rust:cargo-audit\","; \ - echo '{"level":"info","msg":"skipping cargo audit: no Cargo.lock found","language":"rust"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ - if [ -f "deny.toml" ]; then \ - cargo deny check || { overall_exit=1; failed_languages="$${failed_languages}\"rust:cargo-deny\","; }; \ - else \ - echo '{"level":"info","msg":"skipping cargo deny: no deny.toml found","language":"rust"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r rust_root; do \ + if [ "$$rust_root" = "." ]; then rust_tag="rust"; else rust_tag="rust:$$rust_root"; fi; \ + if [ -f "$$rust_root/Cargo.lock" ]; then \ + ran_languages="$${ran_languages}\"$$rust_tag\","; \ + (cd "$$rust_root" && cargo audit) || { overall_exit=1; failed_languages="$${failed_languages}\"$$rust_tag:cargo-audit\","; }; \ + else \ + skipped_languages="$${skipped_languages}\"$$rust_tag:cargo-audit\","; \ + echo "{\"level\":\"info\",\"msg\":\"skipping cargo audit: no Cargo.lock found\",\"language\":\"rust\",\"root\":\"$$rust_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + if [ -f "$$rust_root/deny.toml" ]; then \ + (cd "$$rust_root" && cargo deny check) || { overall_exit=1; failed_languages="$${failed_languages}\"$$rust_tag:cargo-deny\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping cargo deny: no deny.toml found\",\"language\":\"rust\",\"root\":\"$$rust_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots rust); \ fi; \ if [ -n "$(HAS_SWIFT)" ]; then \ skipped_languages="$${skipped_languages}\"swift\","; \ diff --git a/STABILITY.md b/STABILITY.md index e894e1c..94307f2 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -32,6 +32,9 @@ DevRail has reached **v1.0** across all repositories. The core standards, toolch | **Pre-commit hooks** | Stable | Conventional commit hook and per-language hooks configured in template repos. | | **Documentation site** | Stable | [devrail.dev](https://devrail.dev) is live with full standards coverage. | | **Plugin loader + resolver + lockfile + build pipeline + execution loop** | Stable (v1.10.x baseline; reference plugin v1.11.x) | Validates `plugin.devrail.yml` manifests, resolves `rev:` to immutable SHAs (`make plugins-update`), records reproducibility metadata in `.devrail.lock`, and auto-builds a project-local extended image (`devrail-local:`) when plugins are declared. Each loaded plugin's `targets` are dispatched inside `_lint`/`_format`/`_fix`/`_test`/`_security` with gate evaluation, `{paths}` interpolation, per-language overrides, and JSON aggregation into the existing event shape. `DEVRAIL_FAIL_FAST=1` short-circuits on plugin failures the same as core. No-op when `plugins:` is absent — v1.9.x behaviour unchanged. **As of v1.11.0** the first reference plugin ([`devrail-plugin-kotlin`](https://github.com/devrail-dev/devrail-plugin-kotlin)) is published; the extraction is **additive** through the v1.x line (Kotlin remains in core for back-compat). v2.0.0 retires the in-core HAS_ blocks. | +| **Monorepo project-root discovery (Python/JS/Go/Rust)** | Preview (Stories 15.1, 15.3) | `lib/project-discover.sh` autodetects per-language project roots (`pyproject.toml`/`setup.py`/`setup.cfg` for Python, `package.json` for JS/TS, `go.mod` for Go, `Cargo.toml` for Rust) and runs `_lint`/`_format`/`_fix`/`_test`/`_security` with cwd set to each discovered root, so local config (tsconfig, vite aliases) resolves and Go/Rust tools stop failing outright ("directory prefix . does not contain main module", "could not find Cargo.toml") for a module not rooted at the repo root. Optional `.devrail.yml` `projects:` overrides autodetection. Single-root projects (manifest at repo root, the common case) are unaffected. Ansible needs no equivalent — `ansible-lint` already discovers playbooks recursively. | +| **Dependency install before `make test` (Python/JS)** | Preview (Story 15.2) | `lib/dependency-install.sh` autodetects and installs a project's dependencies (`uv`/`pip` for Python — container now ships `uv`; `npm` for JS/TS) in each root discovered above, before `pytest`/`vitest` run. Optional `.devrail.yml` `test.install`/`test.setup` overrides. A failed install fails `make test` immediately. `poetry`/`pipenv`/`pnpm`/`yarn` are not yet supported (not installed in the container) — tracked as follow-on work. | +| **Ephemeral test services (`test.services`)** | Preview (Story 15.4) | `scripts/test-services.sh` starts throwaway `postgres:`/`redis:` containers before `make test` and tears them down afterward (pass or fail), injecting `DATABASE_URL`/`REDIS_URL`. Host-side orchestration only — the toolchain container has no `docker` CLI/socket access. Mutually exclusive with `docker_network` for the `test` target. A `SIGKILL`'d run's orphaned containers/network are detected and cleaned up automatically on the next `make test`. `docker-compose.test.yml` autodetection is not implemented. | ## Consumer responsibilities diff --git a/lib/dependency-install.sh b/lib/dependency-install.sh new file mode 100644 index 0000000..69e5bda --- /dev/null +++ b/lib/dependency-install.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# lib/dependency-install.sh — Dependency installation before `make test` (Story 15.2) +# +# Purpose: Autodetect and install a project's own dependencies before +# pytest/vitest run, so tests don't fail at import time with +# ModuleNotFoundError / unresolved-import errors. Closes issue #52. +# +# Usage: source "${DEVRAIL_LIB}/dependency-install.sh" +# install_project_deps python "$root" || overall_exit=1 +# run_project_setup "$root" || overall_exit=1 +# +# Contract: +# - install_project_deps : installs dependencies for +# in project root (a path relative to the repo root +# the caller is already cwd'd in — the function cd's into it itself). +# Returns the real exit code of the install command. 0 means either +# success or "nothing to install" (no lockfile/manifest found) — never +# swallows a real failure. +# - `.devrail.yml` `test.install` (a shell command string) overrides +# autodetection entirely when present — language-agnostic, applies to +# every root of every declared language. +# - Autodetection (only when no `test.install` override): +# python: uv.lock present -> `uv sync --frozen` +# requirements*.txt present -> `pip install -r ` +# (sorted, first match) +# pyproject.toml/setup.py -> `pip install -e .` +# none of the above -> no-op +# javascript: package-lock.json present -> `npm ci` +# none -> no-op +# Only uv/pip/npm are supported — poetry/pipenv/pnpm/yarn are not +# installed in the container (Story 15.3+ follow-up); their lockfiles +# are intentionally NOT detected here (see Dev Notes in Story 15.2). +# - run_project_setup : runs `.devrail.yml` `test.setup` (a shell +# command string) if present, after a successful install. No-op (0) if +# absent. Also language-agnostic. +# +# Dependencies: lib/log.sh (log_event), yq (v4+), bash 5+, uv, pip, npm + +# Guard against double-sourcing +# shellcheck disable=SC2317 +if [[ -n "${_DEVRAIL_DEPENDENCY_INSTALL_LOADED:-}" ]]; then + return 0 2>/dev/null || true +fi +readonly _DEVRAIL_DEPENDENCY_INSTALL_LOADED=1 + +# Resolved to an ABSOLUTE path at source time (before any caller `cd`s into +# a project root) — install_project_deps/run_project_setup both cd around +# per-root, and .devrail.yml always lives at the repo root regardless of +# which project root is currently active. +_DEPENDENCY_INSTALL_CONFIG="$(pwd)/${DEVRAIL_CONFIG:-.devrail.yml}" + +# _dependency_install_config_value emits the string value at the +# given yq path in .devrail.yml, or empty if missing/absent/unreadable. +# +# Re-invokes yq on every call — once per (language, root) pair per `make +# test` run, since install_project_deps/run_project_setup both call this. +# Deliberately not cached: a handful of extra `yq` subprocess spawns per +# run (milliseconds each) hasn't been shown to matter at this project's +# scale, and caching adds real invalidation/staleness complexity for a +# cost that's currently theoretical. Revisit only if this ever actually +# shows up as slow, not preemptively. +_dependency_install_config_value() { + local yq_path="$1" + [[ -r "${_DEPENDENCY_INSTALL_CONFIG}" ]] || return 0 + yq -r "${yq_path} // \"\"" "${_DEPENDENCY_INSTALL_CONFIG}" 2>/dev/null +} + +# _dependency_install_autodetect_python emits the install command to run +# from within the current directory (assumed to already be the project +# root), or emits nothing when there's no recognized manifest/lockfile. +# +# Installs land in the container's SYSTEM Python site-packages, not an +# isolated venv — this container's model is "tools are installed once, +# globally" (pytest et al. already live in system site-packages), so a +# project dependency has to land there too or the globally-installed +# `pytest` binary (invoked bare, not via any project-local wrapper) will +# never see it. Concretely this means NOT `uv sync` (which creates and +# populates an isolated `.venv/` that bare `pytest` cannot see at all — +# confirmed by hand during Story 15.2 implementation: `uv sync --frozen` +# followed by bare `pytest` still raised ModuleNotFoundError). Instead, +# `uv export` converts the lockfile to a requirements list and `uv pip +# install --system` installs it system-wide, mirroring how the +# requirements.txt/pyproject.toml paths already work below. Both `uv` and +# `pip` need `--break-system-packages` on this container's Debian/PEP 668 +# "externally managed" Python — same flag `scripts/install-python.sh` +# already uses to install the tools themselves. +_dependency_install_autodetect_python() { + if [[ -f "uv.lock" ]]; then + printf 'uv export --frozen --no-hashes --format requirements-txt | uv pip install --system --break-system-packages -r -' + elif compgen -G "requirements*.txt" >/dev/null 2>&1; then + local req_file + if [[ -f "requirements.txt" ]]; then + # Plain requirements.txt wins outright when present, even over an + # alphabetically-earlier variant (e.g. requirements-dev.txt sorts + # before requirements.txt since '-' < '.' in ASCII) — a sort-only + # pick would silently install the wrong file for the common + # requirements.txt + requirements-dev.txt layout. + req_file="requirements.txt" + else + req_file="$(compgen -G "requirements*.txt" | sort | head -1)" + fi + printf 'pip install --break-system-packages -r %q' "${req_file}" + elif [[ -f "pyproject.toml" || -f "setup.py" ]]; then + printf 'pip install --break-system-packages -e .' + fi +} + +# _dependency_install_autodetect_javascript emits the install command, or +# nothing when there's no package-lock.json. +_dependency_install_autodetect_javascript() { + if [[ -f "package-lock.json" ]]; then + printf 'npm ci' + fi +} + +# install_project_deps installs dependencies for the +# given language in the given project root. Returns the install command's +# real exit code; 0 (no-op) when there's nothing to install. +install_project_deps() { + local language="${1:?install_project_deps requires a language}" + local root="${2:?install_project_deps requires a root}" + + local override + override="$(_dependency_install_config_value '.test.install')" + + local cmd + if [[ -n "${override}" ]]; then + cmd="${override}" + else + case "${language}" in + python) cmd="$(cd "${root}" && _dependency_install_autodetect_python)" ;; + javascript) cmd="$(cd "${root}" && _dependency_install_autodetect_javascript)" ;; + *) cmd="" ;; + esac + fi + + if [[ -z "${cmd}" ]]; then + return 0 + fi + + log_event info "installing project dependencies" language="${language}" root="${root}" cmd="${cmd}" + # Capture the real exit code inside an explicit `else` — falling through + # past a bare `if ...; then return 0; fi` looks equivalent but is NOT: + # per POSIX, an `if` with no `else` branch taken exits 0 regardless of + # the condition's own status, so `$?` read after `fi` is always 0, not + # the failed command's code. Confirmed by hand during Story 15.2 + # implementation — the original bare-`fi` version silently returned 0 + # on every install failure, so `make test` ran pytest against a broken + # install instead of failing fast (AC 8). + if (cd "${root}" && bash -c "${cmd}"); then + return 0 + else + local rc=$? + log_event error "dependency install failed" language="${language}" root="${root}" cmd="${cmd}" + return "${rc}" + fi +} + +# run_project_setup runs .devrail.yml `test.setup` (if configured) +# in the given root. No-op (0) if absent. +run_project_setup() { + local root="${1:?run_project_setup requires a root}" + + local setup_cmd + setup_cmd="$(_dependency_install_config_value '.test.setup')" + + if [[ -z "${setup_cmd}" ]]; then + return 0 + fi + + log_event info "running test setup" root="${root}" cmd="${setup_cmd}" + if (cd "${root}" && bash -c "${setup_cmd}"); then + return 0 + else + local rc=$? + log_event error "test setup failed" root="${root}" cmd="${setup_cmd}" + return "${rc}" + fi +} diff --git a/lib/project-discover.sh b/lib/project-discover.sh new file mode 100644 index 0000000..20afa52 --- /dev/null +++ b/lib/project-discover.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# lib/project-discover.sh — Project-root autodiscovery for monorepos (Story 15.1) +# +# Purpose: Discover the directory (or directories) a given language's project +# lives in, so Makefile recipes can run that language's tools with +# the correct cwd instead of always assuming repo root. Closes +# issue #53 (tools ignore subdir project configs in monorepos). +# +# Usage: source "${DEVRAIL_LIB}/project-discover.sh" +# while IFS= read -r root; do ...; done < <(discover_project_roots python) +# +# Contract: +# - Output: newline-separated relative paths, no trailing slash, one or +# more per call — NEVER empty. Callers can always safely loop over the +# result without a "did we find anything" branch. +# - `.devrail.yml` `projects:` (list of {path, languages}) overrides +# autodetection for any language it names. Entries not naming a +# language fall through to autodetection for that language. +# - Autodetection rules (see _project_discover_normalize): +# * a manifest at repo root (.) wins outright — output is just "." +# even if unrelated nested manifests exist elsewhere (e.g. inside an +# examples/ directory) — avoids double-running tools over the same +# files once from "." (which already recurses) and again per nested +# dir. +# * no manifest found anywhere — output is "." — preserves +# pre-Story-15.1 behavior verbatim (tools always ran from repo root +# when no per-project signal existed; Story 15.1 AC 4). +# * otherwise — output is every directory containing a manifest (the +# true monorepo case; Story 15.1 AC 1/AC 7). +# - Excludes .git, node_modules, vendor, .venv, venv, dist, build, target, +# .terraform subtrees (mirrors the per-language find excludes already +# used in _lint/_format/_fix). +# +# Supported languages: python, javascript, go, rust (Story 15.3). Ansible +# was evaluated for Story 15.3 and explicitly excluded — ansible-lint +# already recursively discovers playbooks from cwd regardless of where +# they live, with no root-marker file needed the way go.mod/Cargo.toml +# are for their respective toolchains; there is nothing here for it to +# autodetect. Any other language returns "." with a warning. +# +# Dependencies: lib/log.sh (log_warn), yq (v4+), bash 5+, coreutils (find) + +# Guard against double-sourcing +# shellcheck disable=SC2317 +if [[ -n "${_DEVRAIL_PROJECT_DISCOVER_LOADED:-}" ]]; then + return 0 2>/dev/null || true +fi +readonly _DEVRAIL_PROJECT_DISCOVER_LOADED=1 + +_PROJECT_DISCOVER_CONFIG="${DEVRAIL_CONFIG:-.devrail.yml}" +_PROJECT_DISCOVER_FIND_EXCLUDES=( + -not -path './.git/*' + -not -path './node_modules/*' + -not -path './vendor/*' + -not -path './.venv/*' + -not -path './venv/*' + -not -path './dist/*' + -not -path './build/*' + -not -path './target/*' + -not -path './.terraform/*' +) + +# _project_discover_override emits the projects: path(s) declared for a +# language in .devrail.yml, one per line. Empty if no override applies. +_project_discover_override() { + local language="$1" + [[ -r "${_PROJECT_DISCOVER_CONFIG}" ]] || return 0 + # Delimiter is "::" rather than a tab: yq (mikefarah) does not reliably + # expand \t as an escape in its expression string, so a literal two-char + # backslash-t was previously emitted instead of a real tab, silently + # breaking the awk field split below (caught in Story 15.1 testing). + yq -r '.projects // [] | .[] | [.path, (.languages // [])[]] | join("::")' \ + "${_PROJECT_DISCOVER_CONFIG}" 2>/dev/null | + awk -F'::' -v lang="${language}" '$2 == lang { print $1 }' +} + +# _project_discover_normalize reads candidate directory paths on stdin (one +# per line, already de-duplicated) and applies the "root wins" / "fallback +# to root" rules documented above. Always emits at least one line. +_project_discover_normalize() { + local candidates + candidates="$(cat)" + if [[ -z "${candidates}" ]]; then + printf '.\n' + elif grep -qx '\.' <<<"${candidates}"; then + printf '.\n' + else + printf '%s\n' "${candidates}" + fi +} + +# _project_discover_autodetect_python finds directories containing a Python +# project manifest (pyproject.toml, setup.py, or setup.cfg — first match per +# directory wins so a directory isn't reported twice). +_project_discover_autodetect_python() { + find . \( -name 'pyproject.toml' -o -name 'setup.py' -o -name 'setup.cfg' \) \ + "${_PROJECT_DISCOVER_FIND_EXCLUDES[@]}" -print0 2>/dev/null | + xargs -0 -I{} dirname {} | + sort -u | + sed 's#^\./##' | + _project_discover_normalize +} + +# _project_discover_autodetect_javascript finds directories containing a +# package.json. +_project_discover_autodetect_javascript() { + find . -name 'package.json' \ + "${_PROJECT_DISCOVER_FIND_EXCLUDES[@]}" -print0 2>/dev/null | + xargs -0 -I{} dirname {} | + sort -u | + sed 's#^\./##' | + _project_discover_normalize +} + +# _project_discover_autodetect_go finds directories containing a go.mod. +_project_discover_autodetect_go() { + find . -name 'go.mod' \ + "${_PROJECT_DISCOVER_FIND_EXCLUDES[@]}" -print0 2>/dev/null | + xargs -0 -I{} dirname {} | + sort -u | + sed 's#^\./##' | + _project_discover_normalize +} + +# _project_discover_autodetect_rust finds directories containing a +# Cargo.toml. +_project_discover_autodetect_rust() { + find . -name 'Cargo.toml' \ + "${_PROJECT_DISCOVER_FIND_EXCLUDES[@]}" -print0 2>/dev/null | + xargs -0 -I{} dirname {} | + sort -u | + sed 's#^\./##' | + _project_discover_normalize +} + +# discover_project_roots emits newline-separated project root +# paths for the given language. Never empty — see _project_discover_normalize. +discover_project_roots() { + local language="${1:?discover_project_roots requires a language}" + + local override + override="$(_project_discover_override "${language}")" + if [[ -n "${override}" ]]; then + local override_path + while IFS= read -r override_path; do + [[ -d "${override_path}" ]] || log_warn "projects: path '${override_path}' (language '${language}') does not exist in the repository" + done <<<"${override}" + printf '%s\n' "${override}" + return 0 + fi + + case "${language}" in + python) _project_discover_autodetect_python ;; + javascript) _project_discover_autodetect_javascript ;; + go) _project_discover_autodetect_go ;; + rust) _project_discover_autodetect_rust ;; + *) + log_warn "discover_project_roots: no autodetection rule for language '${language}'" + printf '.\n' + ;; + esac +} diff --git a/scripts/install-python.sh b/scripts/install-python.sh index 6f9050e..e7a5335 100644 --- a/scripts/install-python.sh +++ b/scripts/install-python.sh @@ -12,6 +12,7 @@ # - semgrep (Multi-language SAST) # - pytest (Python test framework) # - mypy (Python static type checker) +# - uv (Python package/dependency manager — Story 15.2) set -euo pipefail @@ -28,7 +29,7 @@ source "${DEVRAIL_LIB}/platform.sh" if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then log_info "install-python.sh — Install Python tooling for DevRail" log_info "Usage: bash scripts/install-python.sh [--help]" - log_info "Tools: ruff, bandit, semgrep, pytest, mypy" + log_info "Tools: ruff, bandit, semgrep, pytest, mypy, uv" exit 0 fi @@ -65,6 +66,7 @@ readonly PYTHON_TOOLS=( "semgrep" "pytest" "mypy" + "uv" ) for tool in "${PYTHON_TOOLS[@]}"; do diff --git a/scripts/test-services.sh b/scripts/test-services.sh new file mode 100644 index 0000000..0cedfb6 --- /dev/null +++ b/scripts/test-services.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# scripts/test-services.sh — Ephemeral Postgres/Redis containers for `make test` (Story 15.4, HOST script) +# +# Purpose: Orchestrates throwaway service containers for integration tests +# declared via `.devrail.yml` `test.services`. Runs on the HOST +# (needs `docker network`/`docker run` access) — the toolchain +# container itself has no Docker CLI or `/var/run/docker.sock` +# mount, deliberately, so this story does not grant it one (that +# would be a real privilege-escalation surface the feature does +# not need). +# +# Usage: bash scripts/test-services.sh up # called by _test-services-up +# bash scripts/test-services.sh down # called by test:'s cleanup trap +# +# Contract: +# - `up` reads `.devrail.yml` `test.services` (list of `postgres:` / +# `redis:` strings). Empty/absent -> no-op, exit 0. +# - Validates the FULL list before starting anything — any unsupported +# entry exits 2 with nothing started (fail fast, not partial-then-fail). +# - Refuses to run if `docker_network` is ALSO set in `.devrail.yml` +# (exit 2) — only one `--network` flag reaches `docker run`, so the two +# are mutually exclusive for the `test` target. +# - Cleans up any stale state left by an incomplete prior run (a +# SIGKILL'd `make test` can't be caught by a shell trap) before +# starting fresh. +# - Writes state under `.devrail/test-services/`: `network` (name), +# `containers` (one name per line), `env` (KEY=VALUE lines, consumed +# via `docker run --env-file`). +# - `down` tears down every tracked container and the tracked network, +# then removes the state dir. No-op if the state dir doesn't exist. +# +# Supported services: `postgres:` (injects DATABASE_URL), `redis:` +# (injects REDIS_URL). Anything else is a hard error — no silent partial +# support for a service this script doesn't know how to configure or +# generate a connection string for. `docker-compose.test.yml` +# autodetection is explicitly out of scope (Story 15.4 AC 9). +# +# Environment: +# DEVRAIL_CONFIG path to .devrail.yml (default: .devrail.yml) +# DEVRAIL_LOG_FORMAT json (default) or human +# +# Dependencies: lib/log.sh, yq (v4+), docker, bash 5+ + +set -euo pipefail +LC_ALL=C +export LC_ALL + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEVRAIL_LIB="${DEVRAIL_LIB:-${SCRIPT_DIR}/../lib}" +# shellcheck source=../lib/log.sh +source "${DEVRAIL_LIB}/log.sh" + +DEVRAIL_CONFIG="${DEVRAIL_CONFIG:-.devrail.yml}" +readonly STATE_DIR=".devrail/test-services" +readonly READY_TIMEOUT_SECONDS=30 + +# --- Help --- +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + log_info "test-services.sh — Ephemeral Postgres/Redis containers for make test" + log_info "Usage: bash scripts/test-services.sh " + exit 0 +fi + +subcommand="${1:?Usage: bash scripts/test-services.sh }" + +# _service_kind emits "postgres"/"redis"/"" (empty = unsupported). +_service_kind() { + case "$1" in + postgres:*) echo "postgres" ;; + redis:*) echo "redis" ;; + *) echo "" ;; + esac +} + +# _wait_ready polls the service's own readiness check +# (not just "the TCP port is open") up to READY_TIMEOUT_SECONDS. +_wait_ready() { + local kind="$1" container="$2" + local elapsed=0 + while ((elapsed < READY_TIMEOUT_SECONDS)); do + case "${kind}" in + postgres) + if docker exec "${container}" pg_isready -U postgres >/dev/null 2>&1; then + return 0 + fi + ;; + redis) + if [[ "$(docker exec "${container}" redis-cli ping 2>/dev/null)" == "PONG" ]]; then + return 0 + fi + ;; + esac + sleep 1 + elapsed=$((elapsed + 1)) + done + return 1 +} + +# _down tears down every tracked container and the tracked network, then +# removes the state dir. No-op if there's nothing to tear down. Individual +# removal failures are logged and skipped, not fatal — a container that's +# already gone (or a network with a lingering endpoint from a container +# docker itself hasn't reaped yet) shouldn't block cleaning up the rest. +_down() { + if [[ ! -d "${STATE_DIR}" ]]; then + return 0 + fi + + if [[ -f "${STATE_DIR}/containers" ]]; then + local container + while IFS= read -r container; do + [[ -z "${container}" ]] && continue + if ! docker rm -f "${container}" >/dev/null 2>&1; then + log_warn "could not remove container '${container}' (already gone?)" + fi + done <"${STATE_DIR}/containers" + fi + + if [[ -f "${STATE_DIR}/network" ]]; then + local network + network="$(cat "${STATE_DIR}/network")" + if ! docker network rm "${network}" >/dev/null 2>&1; then + log_warn "could not remove network '${network}' (already gone?)" + fi + fi + + rm -rf "${STATE_DIR}" + log_event info "test services torn down" +} + +# _up starts every declared test.services entry. See file header for the +# full contract (fail-fast validation, mutual exclusion with +# docker_network, stale-state cleanup). +_up() { + local services + services="$(yq -r '.test.services // [] | .[]' "${DEVRAIL_CONFIG}" 2>/dev/null)" + + if [[ -z "${services}" ]]; then + return 0 + fi + + if [[ -d "${STATE_DIR}" ]]; then + log_warn "found leftover test-services state from a prior run — cleaning up before starting fresh" + _down + fi + + local devrail_network + devrail_network="$(yq -r '.docker_network // ""' "${DEVRAIL_CONFIG}" 2>/dev/null)" + if [[ -n "${devrail_network}" ]]; then + log_error "test.services and docker_network cannot both be set — the test target can only pass one --network flag to docker run. Remove one of them." 2 + exit 2 + fi + + # Validate the full list before starting anything: every entry must be a + # supported kind, and no kind may repeat. A repeat would silently shadow + # itself — DATABASE_URL/REDIS_URL is one env var per kind, so a second + # postgres entry's connection string would overwrite the first's in the + # env file (last line wins), leaving the first container running but + # unreachable via the injected env var for the rest of the test run. + local service kind seen_kinds="" + while IFS= read -r service; do + [[ -z "${service}" ]] && continue + kind="$(_service_kind "${service}")" + if [[ -z "${kind}" ]]; then + log_error "unsupported test.services entry '${service}' — only postgres: and redis: are supported" 2 + exit 2 + fi + if [[ " ${seen_kinds} " == *" ${kind} "* ]]; then + log_error "duplicate test.services entry for '${kind}' — only one ${kind}: entry is supported at a time" 2 + exit 2 + fi + seen_kinds="${seen_kinds} ${kind}" + done <<<"${services}" + + mkdir -p "${STATE_DIR}" + local suffix network + suffix="$(date +%s)-$$" + network="devrail-test-${suffix}" + docker network create "${network}" >/dev/null + echo "${network}" >"${STATE_DIR}/network" + log_event info "created ephemeral test-services network" network="${network}" + + : >"${STATE_DIR}/env" + : >"${STATE_DIR}/containers" + + local index=0 container + while IFS= read -r service; do + [[ -z "${service}" ]] && continue + kind="$(_service_kind "${service}")" + index=$((index + 1)) + container="devrail-test-${kind}-${suffix}-${index}" + + case "${kind}" in + postgres) + docker run -d --network "${network}" --name "${container}" \ + -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=devrail -e POSTGRES_DB=devrail_test \ + "${service}" >/dev/null + ;; + redis) + docker run -d --network "${network}" --name "${container}" "${service}" >/dev/null + ;; + esac + echo "${container}" >>"${STATE_DIR}/containers" + log_event info "starting test service" service="${service}" container="${container}" + + if ! _wait_ready "${kind}" "${container}"; then + log_error "test service '${service}' (${container}) did not become ready within ${READY_TIMEOUT_SECONDS}s" 2 + _down + exit 2 + fi + log_event info "test service ready" service="${service}" container="${container}" + + case "${kind}" in + postgres) + echo "DATABASE_URL=postgresql://postgres:devrail@${container}:5432/devrail_test" >>"${STATE_DIR}/env" + ;; + redis) + echo "REDIS_URL=redis://${container}:6379" >>"${STATE_DIR}/env" + ;; + esac + done <<<"${services}" +} + +case "${subcommand}" in +up) _up ;; +down) _down ;; +*) + log_error "unknown subcommand '${subcommand}' — expected 'up' or 'down'" 2 + exit 2 + ;; +esac diff --git a/tests/fixtures/declared-lang-no-manifest/.devrail.yml b/tests/fixtures/declared-lang-no-manifest/.devrail.yml new file mode 100644 index 0000000..125a346 --- /dev/null +++ b/tests/fixtures/declared-lang-no-manifest/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - python diff --git a/tests/fixtures/go-monorepo/.devrail.yml b/tests/fixtures/go-monorepo/.devrail.yml new file mode 100644 index 0000000..ace6663 --- /dev/null +++ b/tests/fixtures/go-monorepo/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - go diff --git a/tests/fixtures/go-monorepo/services/api/go.mod b/tests/fixtures/go-monorepo/services/api/go.mod new file mode 100644 index 0000000..61beb9f --- /dev/null +++ b/tests/fixtures/go-monorepo/services/api/go.mod @@ -0,0 +1,3 @@ +module example.com/api + +go 1.22 diff --git a/tests/fixtures/go-monorepo/services/api/main.go b/tests/fixtures/go-monorepo/services/api/main.go new file mode 100644 index 0000000..d4ebfb9 --- /dev/null +++ b/tests/fixtures/go-monorepo/services/api/main.go @@ -0,0 +1,7 @@ +package main + +func Add(a, b int) int { + return a + b +} + +func main() {} diff --git a/tests/fixtures/go-monorepo/services/api/main_test.go b/tests/fixtures/go-monorepo/services/api/main_test.go new file mode 100644 index 0000000..0421207 --- /dev/null +++ b/tests/fixtures/go-monorepo/services/api/main_test.go @@ -0,0 +1,9 @@ +package main + +import "testing" + +func TestAdd(t *testing.T) { + if Add(2, 3) != 5 { + t.Fatal("bad") + } +} diff --git a/tests/fixtures/go-multi-root/.devrail.yml b/tests/fixtures/go-multi-root/.devrail.yml new file mode 100644 index 0000000..ace6663 --- /dev/null +++ b/tests/fixtures/go-multi-root/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - go diff --git a/tests/fixtures/go-multi-root/services/a/go.mod b/tests/fixtures/go-multi-root/services/a/go.mod new file mode 100644 index 0000000..df2941f --- /dev/null +++ b/tests/fixtures/go-multi-root/services/a/go.mod @@ -0,0 +1,3 @@ +module example.com/a + +go 1.22 diff --git a/tests/fixtures/go-multi-root/services/a/main.go b/tests/fixtures/go-multi-root/services/a/main.go new file mode 100644 index 0000000..d4ebfb9 --- /dev/null +++ b/tests/fixtures/go-multi-root/services/a/main.go @@ -0,0 +1,7 @@ +package main + +func Add(a, b int) int { + return a + b +} + +func main() {} diff --git a/tests/fixtures/go-multi-root/services/a/main_test.go b/tests/fixtures/go-multi-root/services/a/main_test.go new file mode 100644 index 0000000..0421207 --- /dev/null +++ b/tests/fixtures/go-multi-root/services/a/main_test.go @@ -0,0 +1,9 @@ +package main + +import "testing" + +func TestAdd(t *testing.T) { + if Add(2, 3) != 5 { + t.Fatal("bad") + } +} diff --git a/tests/fixtures/go-multi-root/services/b/go.mod b/tests/fixtures/go-multi-root/services/b/go.mod new file mode 100644 index 0000000..50290ed --- /dev/null +++ b/tests/fixtures/go-multi-root/services/b/go.mod @@ -0,0 +1,3 @@ +module example.com/b + +go 1.22 diff --git a/tests/fixtures/go-multi-root/services/b/main.go b/tests/fixtures/go-multi-root/services/b/main.go new file mode 100644 index 0000000..d4ebfb9 --- /dev/null +++ b/tests/fixtures/go-multi-root/services/b/main.go @@ -0,0 +1,7 @@ +package main + +func Add(a, b int) int { + return a + b +} + +func main() {} diff --git a/tests/fixtures/go-multi-root/services/b/main_test.go b/tests/fixtures/go-multi-root/services/b/main_test.go new file mode 100644 index 0000000..0421207 --- /dev/null +++ b/tests/fixtures/go-multi-root/services/b/main_test.go @@ -0,0 +1,9 @@ +package main + +import "testing" + +func TestAdd(t *testing.T) { + if Add(2, 3) != 5 { + t.Fatal("bad") + } +} diff --git a/tests/fixtures/go-single-root/.devrail.yml b/tests/fixtures/go-single-root/.devrail.yml new file mode 100644 index 0000000..ace6663 --- /dev/null +++ b/tests/fixtures/go-single-root/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - go diff --git a/tests/fixtures/go-single-root/go.mod b/tests/fixtures/go-single-root/go.mod new file mode 100644 index 0000000..485cc0d --- /dev/null +++ b/tests/fixtures/go-single-root/go.mod @@ -0,0 +1,3 @@ +module example.com/single + +go 1.22 diff --git a/tests/fixtures/go-single-root/main.go b/tests/fixtures/go-single-root/main.go new file mode 100644 index 0000000..d4ebfb9 --- /dev/null +++ b/tests/fixtures/go-single-root/main.go @@ -0,0 +1,7 @@ +package main + +func Add(a, b int) int { + return a + b +} + +func main() {} diff --git a/tests/fixtures/go-single-root/main_test.go b/tests/fixtures/go-single-root/main_test.go new file mode 100644 index 0000000..0421207 --- /dev/null +++ b/tests/fixtures/go-single-root/main_test.go @@ -0,0 +1,9 @@ +package main + +import "testing" + +func TestAdd(t *testing.T) { + if Add(2, 3) != 5 { + t.Fatal("bad") + } +} diff --git a/tests/fixtures/js-npm-deps/.devrail.yml b/tests/fixtures/js-npm-deps/.devrail.yml new file mode 100644 index 0000000..ad7f03b --- /dev/null +++ b/tests/fixtures/js-npm-deps/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - javascript diff --git a/tests/fixtures/js-npm-deps/__tests__/smoke.test.js b/tests/fixtures/js-npm-deps/__tests__/smoke.test.js new file mode 100644 index 0000000..84ee1ac --- /dev/null +++ b/tests/fixtures/js-npm-deps/__tests__/smoke.test.js @@ -0,0 +1,8 @@ +import { describe, it, expect } from "vitest"; +import ms from "ms"; + +describe("ms dependency", () => { + it("is installed and importable", () => { + expect(ms("2 days")).toBe(172800000); + }); +}); diff --git a/tests/fixtures/js-npm-deps/package-lock.json b/tests/fixtures/js-npm-deps/package-lock.json new file mode 100644 index 0000000..7c8b9a3 --- /dev/null +++ b/tests/fixtures/js-npm-deps/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "npm-deps-demo", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "npm-deps-demo", + "version": "0.1.0", + "dependencies": { + "ms": "2.1.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + } + } +} diff --git a/tests/fixtures/js-npm-deps/package.json b/tests/fixtures/js-npm-deps/package.json new file mode 100644 index 0000000..b38662a --- /dev/null +++ b/tests/fixtures/js-npm-deps/package.json @@ -0,0 +1,8 @@ +{ + "name": "npm-deps-demo", + "version": "0.1.0", + "type": "module", + "dependencies": { + "ms": "2.1.3" + } +} diff --git a/tests/fixtures/monorepo-python-js/.devrail.yml b/tests/fixtures/monorepo-python-js/.devrail.yml new file mode 100644 index 0000000..1499aa8 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/.devrail.yml @@ -0,0 +1,3 @@ +languages: + - python + - javascript diff --git a/tests/fixtures/monorepo-python-js/api/app.py b/tests/fixtures/monorepo-python-js/api/app.py new file mode 100644 index 0000000..4693ad3 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/api/app.py @@ -0,0 +1,2 @@ +def add(a, b): + return a + b diff --git a/tests/fixtures/monorepo-python-js/api/pyproject.toml b/tests/fixtures/monorepo-python-js/api/pyproject.toml new file mode 100644 index 0000000..f743668 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/api/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "api-demo" +version = "0.1.0" diff --git a/tests/fixtures/monorepo-python-js/api/tests/test_smoke.py b/tests/fixtures/monorepo-python-js/api/tests/test_smoke.py new file mode 100644 index 0000000..82eb92d --- /dev/null +++ b/tests/fixtures/monorepo-python-js/api/tests/test_smoke.py @@ -0,0 +1,2 @@ +def test_smoke(): + assert 1 + 1 == 2 diff --git a/tests/fixtures/monorepo-python-js/frontend/eslint.config.js b/tests/fixtures/monorepo-python-js/frontend/eslint.config.js new file mode 100644 index 0000000..bc377f1 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/frontend/eslint.config.js @@ -0,0 +1,6 @@ +export default [ + { + files: ["**/*.js"], + rules: {}, + }, +]; diff --git a/tests/fixtures/monorepo-python-js/frontend/package.json b/tests/fixtures/monorepo-python-js/frontend/package.json new file mode 100644 index 0000000..6e56dec --- /dev/null +++ b/tests/fixtures/monorepo-python-js/frontend/package.json @@ -0,0 +1,5 @@ +{ + "name": "frontend-demo", + "version": "0.1.0", + "type": "module" +} diff --git a/tests/fixtures/monorepo-python-js/frontend/src/__tests__/smoke.test.ts b/tests/fixtures/monorepo-python-js/frontend/src/__tests__/smoke.test.ts new file mode 100644 index 0000000..bee41a7 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/frontend/src/__tests__/smoke.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from "vitest"; +import { greet } from "@/greet"; + +describe("greet", () => { + it("resolves the @ alias and greets", () => { + expect(greet("world")).toBe("hello, world"); + }); +}); diff --git a/tests/fixtures/monorepo-python-js/frontend/src/greet.ts b/tests/fixtures/monorepo-python-js/frontend/src/greet.ts new file mode 100644 index 0000000..38b5dc3 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/frontend/src/greet.ts @@ -0,0 +1,3 @@ +export function greet(name: string): string { + return `hello, ${name}`; +} diff --git a/tests/fixtures/monorepo-python-js/frontend/src/index.js b/tests/fixtures/monorepo-python-js/frontend/src/index.js new file mode 100644 index 0000000..b41a6df --- /dev/null +++ b/tests/fixtures/monorepo-python-js/frontend/src/index.js @@ -0,0 +1 @@ +export const version = "0.1.0"; diff --git a/tests/fixtures/monorepo-python-js/frontend/tsconfig.json b/tests/fixtures/monorepo-python-js/frontend/tsconfig.json new file mode 100644 index 0000000..408ecd4 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/frontend/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src/greet.ts"] +} diff --git a/tests/fixtures/monorepo-python-js/frontend/vite.config.ts b/tests/fixtures/monorepo-python-js/frontend/vite.config.ts new file mode 100644 index 0000000..c134e77 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/frontend/vite.config.ts @@ -0,0 +1,7 @@ +export default { + resolve: { + alias: { + "@": new URL("./src", import.meta.url).pathname, + }, + }, +}; diff --git a/tests/fixtures/monorepo-with-override/.devrail.yml b/tests/fixtures/monorepo-with-override/.devrail.yml new file mode 100644 index 0000000..7ad2f7e --- /dev/null +++ b/tests/fixtures/monorepo-with-override/.devrail.yml @@ -0,0 +1,5 @@ +languages: + - python +projects: + - path: custom-py-dir + languages: [python] diff --git a/tests/fixtures/monorepo-with-override/custom-py-dir/app.py b/tests/fixtures/monorepo-with-override/custom-py-dir/app.py new file mode 100644 index 0000000..4693ad3 --- /dev/null +++ b/tests/fixtures/monorepo-with-override/custom-py-dir/app.py @@ -0,0 +1,2 @@ +def add(a, b): + return a + b diff --git a/tests/fixtures/multi-root-python/services/a/pyproject.toml b/tests/fixtures/multi-root-python/services/a/pyproject.toml new file mode 100644 index 0000000..38c626f --- /dev/null +++ b/tests/fixtures/multi-root-python/services/a/pyproject.toml @@ -0,0 +1 @@ +[project] diff --git a/tests/fixtures/multi-root-python/services/b/pyproject.toml b/tests/fixtures/multi-root-python/services/b/pyproject.toml new file mode 100644 index 0000000..38c626f --- /dev/null +++ b/tests/fixtures/multi-root-python/services/b/pyproject.toml @@ -0,0 +1 @@ +[project] diff --git a/tests/fixtures/python-install-fails/.devrail.yml b/tests/fixtures/python-install-fails/.devrail.yml new file mode 100644 index 0000000..125a346 --- /dev/null +++ b/tests/fixtures/python-install-fails/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - python diff --git a/tests/fixtures/python-install-fails/requirements.txt b/tests/fixtures/python-install-fails/requirements.txt new file mode 100644 index 0000000..861a8bd --- /dev/null +++ b/tests/fixtures/python-install-fails/requirements.txt @@ -0,0 +1 @@ +this-package-definitely-does-not-exist-anywhere==99.99.99 diff --git a/tests/fixtures/python-install-fails/tests/test_smoke.py b/tests/fixtures/python-install-fails/tests/test_smoke.py new file mode 100644 index 0000000..6370b5d --- /dev/null +++ b/tests/fixtures/python-install-fails/tests/test_smoke.py @@ -0,0 +1,2 @@ +def test_should_never_run(): + raise AssertionError("pytest ran despite a failed dependency install — AC 8 violated") diff --git a/tests/fixtures/python-multi-requirements/.devrail.yml b/tests/fixtures/python-multi-requirements/.devrail.yml new file mode 100644 index 0000000..125a346 --- /dev/null +++ b/tests/fixtures/python-multi-requirements/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - python diff --git a/tests/fixtures/python-multi-requirements/requirements-dev.txt b/tests/fixtures/python-multi-requirements/requirements-dev.txt new file mode 100644 index 0000000..861a8bd --- /dev/null +++ b/tests/fixtures/python-multi-requirements/requirements-dev.txt @@ -0,0 +1 @@ +this-package-definitely-does-not-exist-anywhere==99.99.99 diff --git a/tests/fixtures/python-multi-requirements/requirements.txt b/tests/fixtures/python-multi-requirements/requirements.txt new file mode 100644 index 0000000..4aec4ad --- /dev/null +++ b/tests/fixtures/python-multi-requirements/requirements.txt @@ -0,0 +1 @@ +inflection==0.5.1 diff --git a/tests/fixtures/python-multi-requirements/tests/test_smoke.py b/tests/fixtures/python-multi-requirements/tests/test_smoke.py new file mode 100644 index 0000000..9174a61 --- /dev/null +++ b/tests/fixtures/python-multi-requirements/tests/test_smoke.py @@ -0,0 +1,4 @@ +def test_requirements_txt_wins_over_requirements_dev_txt(): + import inflection + + assert inflection.underscore("HelloWorld") == "hello_world" diff --git a/tests/fixtures/python-pyproject-only/.devrail.yml b/tests/fixtures/python-pyproject-only/.devrail.yml new file mode 100644 index 0000000..125a346 --- /dev/null +++ b/tests/fixtures/python-pyproject-only/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - python diff --git a/tests/fixtures/python-pyproject-only/pyproject.toml b/tests/fixtures/python-pyproject-only/pyproject.toml new file mode 100644 index 0000000..6acbf9f --- /dev/null +++ b/tests/fixtures/python-pyproject-only/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "pyproject-only-demo" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = ["inflection"] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" diff --git a/tests/fixtures/python-pyproject-only/tests/test_smoke.py b/tests/fixtures/python-pyproject-only/tests/test_smoke.py new file mode 100644 index 0000000..36a4afe --- /dev/null +++ b/tests/fixtures/python-pyproject-only/tests/test_smoke.py @@ -0,0 +1,4 @@ +def test_dependency_is_importable(): + import inflection + + assert inflection.underscore("HelloWorld") == "hello_world" diff --git a/tests/fixtures/python-requirements-deps/.devrail.yml b/tests/fixtures/python-requirements-deps/.devrail.yml new file mode 100644 index 0000000..125a346 --- /dev/null +++ b/tests/fixtures/python-requirements-deps/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - python diff --git a/tests/fixtures/python-requirements-deps/requirements.txt b/tests/fixtures/python-requirements-deps/requirements.txt new file mode 100644 index 0000000..bc2ebfe --- /dev/null +++ b/tests/fixtures/python-requirements-deps/requirements.txt @@ -0,0 +1 @@ +humanize==4.11.0 diff --git a/tests/fixtures/python-requirements-deps/tests/test_smoke.py b/tests/fixtures/python-requirements-deps/tests/test_smoke.py new file mode 100644 index 0000000..404e642 --- /dev/null +++ b/tests/fixtures/python-requirements-deps/tests/test_smoke.py @@ -0,0 +1,4 @@ +def test_dependency_is_importable(): + import humanize + + assert humanize.naturalsize(1000000) == "1.0 MB" diff --git a/tests/fixtures/python-uv-deps/.devrail.yml b/tests/fixtures/python-uv-deps/.devrail.yml new file mode 100644 index 0000000..125a346 --- /dev/null +++ b/tests/fixtures/python-uv-deps/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - python diff --git a/tests/fixtures/python-uv-deps/pyproject.toml b/tests/fixtures/python-uv-deps/pyproject.toml new file mode 100644 index 0000000..3a7525d --- /dev/null +++ b/tests/fixtures/python-uv-deps/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "uv-deps-demo" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = ["first"] diff --git a/tests/fixtures/python-uv-deps/tests/test_smoke.py b/tests/fixtures/python-uv-deps/tests/test_smoke.py new file mode 100644 index 0000000..e76f203 --- /dev/null +++ b/tests/fixtures/python-uv-deps/tests/test_smoke.py @@ -0,0 +1,4 @@ +def test_dependency_is_importable(): + from first import first + + assert first([0, False, None, 3, 4]) == 3 diff --git a/tests/fixtures/python-uv-deps/uv.lock b/tests/fixtures/python-uv-deps/uv.lock new file mode 100644 index 0000000..c170ea0 --- /dev/null +++ b/tests/fixtures/python-uv-deps/uv.lock @@ -0,0 +1,23 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "first" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/a2/78a4e6801fbd789c60888afb8e28ccbe629f9a25137bfafecb363db2fb53/first-2.0.2.tar.gz", hash = "sha256:ff285b08c55f8c97ce4ea7012743af2495c9f1291785f163722bd36f6af6d3bf", size = 6964, upload-time = "2019-03-07T10:07:28.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/62/eda58db762d4845c971029becfecf6e85ad71c3bcba7d400598013a222a1/first-2.0.2-py2.py3-none-any.whl", hash = "sha256:8d8e46e115ea8ac652c76123c0865e3ff18372aef6f03c22809ceefcea9dec86", size = 5359, upload-time = "2019-03-07T10:07:26.535Z" }, +] + +[[package]] +name = "uv-deps-demo" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "first" }, +] + +[package.metadata] +requires-dist = [{ name = "first" }] diff --git a/tests/fixtures/rust-monorepo/.devrail.yml b/tests/fixtures/rust-monorepo/.devrail.yml new file mode 100644 index 0000000..d61c510 --- /dev/null +++ b/tests/fixtures/rust-monorepo/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - rust diff --git a/tests/fixtures/rust-monorepo/services/api/Cargo.toml b/tests/fixtures/rust-monorepo/services/api/Cargo.toml new file mode 100644 index 0000000..773000d --- /dev/null +++ b/tests/fixtures/rust-monorepo/services/api/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "api" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/rust-monorepo/services/api/src/lib.rs b/tests/fixtures/rust-monorepo/services/api/src/lib.rs new file mode 100644 index 0000000..bf2f73c --- /dev/null +++ b/tests/fixtures/rust-monorepo/services/api/src/lib.rs @@ -0,0 +1,12 @@ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn it_adds() { + assert_eq!(add(2, 3), 5); + } +} diff --git a/tests/fixtures/rust-multi-root/.devrail.yml b/tests/fixtures/rust-multi-root/.devrail.yml new file mode 100644 index 0000000..d61c510 --- /dev/null +++ b/tests/fixtures/rust-multi-root/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - rust diff --git a/tests/fixtures/rust-multi-root/services/a/Cargo.toml b/tests/fixtures/rust-multi-root/services/a/Cargo.toml new file mode 100644 index 0000000..d06892e --- /dev/null +++ b/tests/fixtures/rust-multi-root/services/a/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "a" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/rust-multi-root/services/a/src/lib.rs b/tests/fixtures/rust-multi-root/services/a/src/lib.rs new file mode 100644 index 0000000..bf2f73c --- /dev/null +++ b/tests/fixtures/rust-multi-root/services/a/src/lib.rs @@ -0,0 +1,12 @@ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn it_adds() { + assert_eq!(add(2, 3), 5); + } +} diff --git a/tests/fixtures/rust-multi-root/services/b/Cargo.toml b/tests/fixtures/rust-multi-root/services/b/Cargo.toml new file mode 100644 index 0000000..068550f --- /dev/null +++ b/tests/fixtures/rust-multi-root/services/b/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "b" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/rust-multi-root/services/b/src/lib.rs b/tests/fixtures/rust-multi-root/services/b/src/lib.rs new file mode 100644 index 0000000..bf2f73c --- /dev/null +++ b/tests/fixtures/rust-multi-root/services/b/src/lib.rs @@ -0,0 +1,12 @@ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn it_adds() { + assert_eq!(add(2, 3), 5); + } +} diff --git a/tests/fixtures/rust-single-root/.devrail.yml b/tests/fixtures/rust-single-root/.devrail.yml new file mode 100644 index 0000000..d61c510 --- /dev/null +++ b/tests/fixtures/rust-single-root/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - rust diff --git a/tests/fixtures/rust-single-root/Cargo.toml b/tests/fixtures/rust-single-root/Cargo.toml new file mode 100644 index 0000000..61a5efc --- /dev/null +++ b/tests/fixtures/rust-single-root/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "single" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/rust-single-root/src/lib.rs b/tests/fixtures/rust-single-root/src/lib.rs new file mode 100644 index 0000000..bf2f73c --- /dev/null +++ b/tests/fixtures/rust-single-root/src/lib.rs @@ -0,0 +1,12 @@ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn it_adds() { + assert_eq!(add(2, 3), 5); + } +} diff --git a/tests/fixtures/single-root-python/.devrail.yml b/tests/fixtures/single-root-python/.devrail.yml new file mode 100644 index 0000000..125a346 --- /dev/null +++ b/tests/fixtures/single-root-python/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - python diff --git a/tests/fixtures/single-root-python/app.py b/tests/fixtures/single-root-python/app.py new file mode 100644 index 0000000..4693ad3 --- /dev/null +++ b/tests/fixtures/single-root-python/app.py @@ -0,0 +1,2 @@ +def add(a, b): + return a + b diff --git a/tests/fixtures/single-root-python/pyproject.toml b/tests/fixtures/single-root-python/pyproject.toml new file mode 100644 index 0000000..5c2b375 --- /dev/null +++ b/tests/fixtures/single-root-python/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "single-root-demo" +version = "0.1.0" diff --git a/tests/fixtures/single-root-python/tests/test_smoke.py b/tests/fixtures/single-root-python/tests/test_smoke.py new file mode 100644 index 0000000..82eb92d --- /dev/null +++ b/tests/fixtures/single-root-python/tests/test_smoke.py @@ -0,0 +1,2 @@ +def test_smoke(): + assert 1 + 1 == 2 diff --git a/tests/fixtures/test-install-override/.devrail.yml b/tests/fixtures/test-install-override/.devrail.yml new file mode 100644 index 0000000..2fd8ffd --- /dev/null +++ b/tests/fixtures/test-install-override/.devrail.yml @@ -0,0 +1,4 @@ +languages: + - python +test: + install: "pip install --break-system-packages first" diff --git a/tests/fixtures/test-install-override/requirements.txt b/tests/fixtures/test-install-override/requirements.txt new file mode 100644 index 0000000..861a8bd --- /dev/null +++ b/tests/fixtures/test-install-override/requirements.txt @@ -0,0 +1 @@ +this-package-definitely-does-not-exist-anywhere==99.99.99 diff --git a/tests/fixtures/test-install-override/tests/test_smoke.py b/tests/fixtures/test-install-override/tests/test_smoke.py new file mode 100644 index 0000000..5622ea0 --- /dev/null +++ b/tests/fixtures/test-install-override/tests/test_smoke.py @@ -0,0 +1,4 @@ +def test_override_was_used_not_autodetected_requirements_txt(): + from first import first + + assert first([0, None, 5]) == 5 diff --git a/tests/fixtures/test-services-mutex/.devrail.yml b/tests/fixtures/test-services-mutex/.devrail.yml new file mode 100644 index 0000000..9a56fea --- /dev/null +++ b/tests/fixtures/test-services-mutex/.devrail.yml @@ -0,0 +1,6 @@ +languages: + - python +docker_network: some-existing-network +test: + services: + - postgres:16 diff --git a/tests/fixtures/test-services-pg-redis/.devrail.yml b/tests/fixtures/test-services-pg-redis/.devrail.yml new file mode 100644 index 0000000..d190c88 --- /dev/null +++ b/tests/fixtures/test-services-pg-redis/.devrail.yml @@ -0,0 +1,6 @@ +languages: + - python +test: + services: + - postgres:16 + - redis:7 diff --git a/tests/fixtures/test-services-pg-redis/requirements.txt b/tests/fixtures/test-services-pg-redis/requirements.txt new file mode 100644 index 0000000..a76af2e --- /dev/null +++ b/tests/fixtures/test-services-pg-redis/requirements.txt @@ -0,0 +1,2 @@ +psycopg2-binary==2.9.10 +redis==5.2.1 diff --git a/tests/fixtures/test-services-pg-redis/tests/test_services.py b/tests/fixtures/test-services-pg-redis/tests/test_services.py new file mode 100644 index 0000000..1b5505d --- /dev/null +++ b/tests/fixtures/test-services-pg-redis/tests/test_services.py @@ -0,0 +1,20 @@ +import os + +import psycopg2 +import redis + + +def test_postgres_is_reachable_and_queryable(): + conn = psycopg2.connect(os.environ["DATABASE_URL"]) + try: + with conn.cursor() as cur: + cur.execute("SELECT 1;") + assert cur.fetchone() == (1,) + finally: + conn.close() + + +def test_redis_is_reachable_and_usable(): + client = redis.from_url(os.environ["REDIS_URL"]) + client.set("story-15-4", "works") + assert client.get("story-15-4") == b"works" diff --git a/tests/fixtures/test-services-unsupported/.devrail.yml b/tests/fixtures/test-services-unsupported/.devrail.yml new file mode 100644 index 0000000..4cf8608 --- /dev/null +++ b/tests/fixtures/test-services-unsupported/.devrail.yml @@ -0,0 +1,5 @@ +languages: + - python +test: + services: + - mysql:8 diff --git a/tests/fixtures/test-setup-ordering/.devrail.yml b/tests/fixtures/test-setup-ordering/.devrail.yml new file mode 100644 index 0000000..fc94462 --- /dev/null +++ b/tests/fixtures/test-setup-ordering/.devrail.yml @@ -0,0 +1,4 @@ +languages: + - python +test: + setup: "touch setup-ran.marker" diff --git a/tests/fixtures/test-setup-ordering/tests/test_smoke.py b/tests/fixtures/test-setup-ordering/tests/test_smoke.py new file mode 100644 index 0000000..f7bcf7d --- /dev/null +++ b/tests/fixtures/test-setup-ordering/tests/test_smoke.py @@ -0,0 +1,5 @@ +import os + + +def test_setup_ran_before_tests(): + assert os.path.exists("setup-ran.marker") diff --git a/tests/test-dependency-install.sh b/tests/test-dependency-install.sh new file mode 100644 index 0000000..4dd7a4a --- /dev/null +++ b/tests/test-dependency-install.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# tests/test-dependency-install.sh — Validate dependency install before `make test` (Story 15.2) +# +# Verifies, against checked-in fixtures under tests/fixtures/, that `make +# _test` installs a project's own dependencies before pytest/vitest run: +# 1. uv.lock -> `uv export | uv pip install --system` (Python) +# 2. requirements*.txt -> `pip install -r ` (Python) +# 3. pyproject.toml only -> `pip install -e .` (Python) +# 4. package-lock.json -> `npm ci` (JS) +# 5. `.devrail.yml` test.install overrides autodetection +# 6. `.devrail.yml` test.setup runs after install, before the test suite +# 7. A failed install fails `make test` fast — the test suite never runs +# against a broken/partial install (AC 8) +# 8. A project with no lockfile/manifest for a declared language is +# unaffected — no install step runs (AC 7, regression safety; reuses +# Story 15.1's declared-lang-no-manifest and monorepo-python-js +# fixtures for the Python and JS sides respectively) +# 9. requirements.txt wins over requirements-dev.txt (or any other +# requirements*.txt variant) rather than picking whichever sorts +# first alphabetically — '-' sorts before '.' in ASCII, so a naive +# sorted-glob pick would silently prefer requirements-dev.txt +# +# Every installing case here does a REAL network install against PyPI/npm +# inside the container — this is intentional, not an oversight. The whole +# point of this story is that a real install unblocks a real import; a +# mocked network call would prove nothing about the actual bug in issue +# #52. Requires network egress (present in CI; present in any normal dev +# environment). +# +# Fixtures are copied into a disposable $WORKDIR before any `make` target +# runs against them, never bind-mounted read-write directly — an install +# step creates root-owned artifacts (site-packages entries, node_modules/), +# and Story 15.1's own test script had to be rewritten once already after +# a Docker bind-mount quirk leaked a stray file into tracked fixtures from +# doing exactly that. See tests/test-project-discover.sh for the same +# pattern. +# +# Usage: bash tests/test-dependency-install.sh +# Env: +# DEVRAIL_IMAGE override image name (default: ghcr.io/devrail-dev/dev-toolchain) +# DEVRAIL_TAG override image tag (default: local) + +set -euo pipefail + +IMAGE="${DEVRAIL_IMAGE:-ghcr.io/devrail-dev/dev-toolchain}:${DEVRAIL_TAG:-local}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FIXTURES="${REPO_ROOT}/tests/fixtures" +WORKDIR="$(mktemp -d)" + +cleanup() { + if [ -n "${WORKDIR:-}" ] && [ -d "$WORKDIR" ]; then + docker run --rm -v "$WORKDIR:/cleanup" "$IMAGE" \ + sh -c 'rm -rf /cleanup/* /cleanup/.[!.]* 2>/dev/null || true' >/dev/null 2>&1 || true + rmdir "$WORKDIR" 2>/dev/null || rm -rf "$WORKDIR" 2>/dev/null || true + fi +} +trap cleanup EXIT + +PASS=0 +FAIL=0 + +# assert_eq EXPECTED ACTUAL CONTEXT +assert_eq() { + local expected="$1" actual="$2" context="$3" + if [ "$expected" = "$actual" ]; then + echo "PASS [$context]" + PASS=$((PASS + 1)) + else + echo "FAIL [$context]: expected '$expected', got '$actual'" >&2 + FAIL=$((FAIL + 1)) + fi +} + +# run_test FIXTURE -> the JSON summary line for `make _test` against a +# disposable copy of the fixture, with the real Makefile mounted in. +run_test() { + local fixture="$1" + local ws="${WORKDIR}/${fixture}" + cp -R "${FIXTURES}/${fixture}" "$ws" + local out + out=$(docker run --rm \ + -e DEVRAIL_LOG_FORMAT=json \ + -v "${ws}:/workspace" \ + -v "${REPO_ROOT}/Makefile:/workspace/Makefile:ro" \ + -w /workspace \ + "$IMAGE" \ + make _test 2>&1) || true + printf '%s\n' "$out" | grep -o '{"target":"test".*}' | tail -1 || true +} + +echo "==> python-uv-deps: uv.lock -> uv export | uv pip install --system" +SUMMARY=$(run_test python-uv-deps) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "uv-deps/status" + +echo "==> python-requirements-deps: requirements.txt -> pip install -r" +SUMMARY=$(run_test python-requirements-deps) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "requirements-deps/status" + +echo "==> python-pyproject-only: pyproject.toml only -> pip install -e ." +SUMMARY=$(run_test python-pyproject-only) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "pyproject-only/status" + +echo "==> python-multi-requirements: requirements.txt wins over requirements-dev.txt (not alphabetical sort)" +SUMMARY=$(run_test python-multi-requirements) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "multi-requirements/status" + +echo "==> js-npm-deps: package-lock.json -> npm ci" +SUMMARY=$(run_test js-npm-deps) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "npm-deps/status" + +echo "==> test-install-override: test.install wins over a requirements.txt that would otherwise fail" +SUMMARY=$(run_test test-install-override) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "install-override/status" + +echo "==> test-setup-ordering: test.setup runs after install (no-op here), before the test suite" +SUMMARY=$(run_test test-setup-ordering) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "setup-ordering/status" + +echo "==> python-install-fails: a broken install fails fast — pytest must never run (AC 8)" +INSTALL_FAILS_OUT_WS="${WORKDIR}/python-install-fails-raw" +cp -R "${FIXTURES}/python-install-fails" "$INSTALL_FAILS_OUT_WS" +RAW_OUT=$(docker run --rm \ + -e DEVRAIL_LOG_FORMAT=json \ + -v "${INSTALL_FAILS_OUT_WS}:/workspace" \ + -v "${REPO_ROOT}/Makefile:/workspace/Makefile:ro" \ + -w /workspace \ + "$IMAGE" \ + make _test 2>&1) || true +SUMMARY=$(printf '%s\n' "$RAW_OUT" | grep -o '{"target":"test".*}' | tail -1) || true +assert_eq "fail" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "install-fails/status" +assert_eq '["python:install"]' "$(printf '%s' "$SUMMARY" | jq -c '.failed')" "install-fails/failed-tag" +if printf '%s\n' "$RAW_OUT" | grep -q "pytest ran despite a failed dependency install"; then + echo "FAIL [install-fails/pytest-did-not-run]: pytest executed the test suite despite a failed install" >&2 + FAIL=$((FAIL + 1)) +else + echo "PASS [install-fails/pytest-did-not-run]" + PASS=$((PASS + 1)) +fi + +echo "==> declared-lang-no-manifest (Story 15.1 fixture): no lockfile/manifest (Python) -> no install attempted, unaffected" +SUMMARY=$(run_test declared-lang-no-manifest) +assert_eq "skip" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "no-manifest/status" + +echo "==> monorepo-python-js (Story 15.1 fixture): frontend/ has package.json but no package-lock.json -> JS install no-op, unaffected" +SUMMARY=$(run_test monorepo-python-js) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "js-no-lockfile/status" + +echo "" +echo "===================================" +echo "Results: ${PASS} passed, ${FAIL} failed" +echo "===================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi diff --git a/tests/test-project-discover.sh b/tests/test-project-discover.sh new file mode 100644 index 0000000..943dd4c --- /dev/null +++ b/tests/test-project-discover.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# tests/test-project-discover.sh — Validate project-root autodiscovery (Story 15.1) +# +# Verifies, against checked-in fixtures under tests/fixtures/: +# 1. discover_project_roots resolves to "." for a single-language repo with +# manifests at repo root (regression safety — Story 15.1 AC 4). +# 2. discover_project_roots finds per-language roots in a two-language +# monorepo (api/ Python + frontend/ JS) with no manifests at root. +# 3. discover_project_roots finds multiple roots for a single language +# (services/a, services/b) and runs each independently. +# 4. .devrail.yml `projects:` overrides autodetection. +# 5. A declared language with no manifest anywhere falls back to "." rather +# than being skipped (also a regression-safety guarantee). +# 6. The full `make _lint`/`make _format`/`make _fix`/`make _test`/ +# `make _security` recipes all run each language's tools with the +# correct cwd — proving config/alias resolution, not just path +# discovery (frontend/tsconfig.json + vite.config.ts `@` alias) — and +# tag per-root failures/skips (e.g. "python:api:bandit") the same way +# lint does. +# 7. (Story 15.3) discover_project_roots go/rust and full make _lint/ +# _format/_fix/_test/_security against real Go/Rust monorepo and +# multi-root fixtures — proving this story actually fixes the +# reproduced pre-existing failures ("directory prefix . does not +# contain main module", "could not find Cargo.toml") rather than +# just not crashing, and that multiple module roots run and tag +# independently (AC 7 — added during code-review; the first pass +# only covered single-nested-module lint/test, mirroring the exact +# class of gap Story 15.1's own review caught). +# +# Fixtures are copied into a disposable $WORKDIR before any `make` target +# runs against them (never bind-mounted read-write directly) — running a +# target can leave root-owned artifacts (.ruff_cache/, __pycache__/) or, via +# a Docker bind-mount quirk, an empty placeholder file at any container path +# mounted from a host path that doesn't yet exist (e.g. mounting the real +# Makefile at /workspace/Makefile creates a stray host-side "Makefile" if +# /workspace is itself a live bind mount of a fixture that has none). +# Mounting a throwaway copy, cleaned up via trap, keeps tests/fixtures/ +# clean regardless. Matches the tests/test-plugin-loader.sh convention. +# +# Usage: bash tests/test-project-discover.sh +# Env: +# DEVRAIL_IMAGE override image name (default: ghcr.io/devrail-dev/dev-toolchain) +# DEVRAIL_TAG override image tag (default: local) + +set -euo pipefail + +IMAGE="${DEVRAIL_IMAGE:-ghcr.io/devrail-dev/dev-toolchain}:${DEVRAIL_TAG:-local}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FIXTURES="${REPO_ROOT}/tests/fixtures" +WORKDIR="$(mktemp -d)" + +cleanup() { + if [ -n "${WORKDIR:-}" ] && [ -d "$WORKDIR" ]; then + docker run --rm -v "$WORKDIR:/cleanup" "$IMAGE" \ + sh -c 'rm -rf /cleanup/* /cleanup/.[!.]* 2>/dev/null || true' >/dev/null 2>&1 || true + rmdir "$WORKDIR" 2>/dev/null || rm -rf "$WORKDIR" 2>/dev/null || true + fi +} +trap cleanup EXIT + +PASS=0 +FAIL=0 + +# assert_eq EXPECTED ACTUAL CONTEXT +assert_eq() { + local expected="$1" actual="$2" context="$3" + if [ "$expected" = "$actual" ]; then + echo "PASS [$context]" + PASS=$((PASS + 1)) + else + echo "FAIL [$context]: expected '$expected', got '$actual'" >&2 + FAIL=$((FAIL + 1)) + fi +} + +# workspace_for FIXTURE -> path to a disposable copy of the fixture under +# $WORKDIR, created on first use and reused for subsequent calls with the +# same fixture name within this run. +workspace_for() { + local fixture="$1" + local dest="${WORKDIR}/${fixture}" + if [ ! -d "$dest" ]; then + cp -R "${FIXTURES}/${fixture}" "$dest" + fi + printf '%s' "$dest" +} + +# discover FIXTURE LANGUAGE -> newline-separated roots (unit-level: sources +# lib/project-discover.sh directly against the fixture dir, no Makefile). +discover() { + local fixture="$1" language="$2" + docker run --rm \ + -v "${REPO_ROOT}/lib:/testlib:ro" \ + -v "${FIXTURES}/${fixture}:/workspace:ro" \ + -w /workspace \ + "$IMAGE" \ + bash -c "source /opt/devrail/lib/log.sh; source /testlib/project-discover.sh; discover_project_roots '${language}'" +} + +# run_target FIXTURE MAKE-TARGET -> the JSON summary line for that target +# (e.g. '{"target":"format","status":"pass",...}'). Runs against a disposable +# copy of the fixture with the real Makefile mounted in, matching the +# tests/test-plugin-loader.sh convention. +run_target() { + local fixture="$1" target="$2" + local ws + ws="$(workspace_for "$fixture")" + local out + out=$(docker run --rm \ + -e DEVRAIL_LOG_FORMAT=json \ + -v "${ws}:/workspace" \ + -v "${REPO_ROOT}/Makefile:/workspace/Makefile:ro" \ + -w /workspace \ + "$IMAGE" \ + make "_${target}" 2>&1) || true + printf '%s\n' "$out" | grep -o "{\"target\":\"${target}\".*}" | tail -1 || true +} + +echo "==> Unit: single-root-python — python resolves to '.' (regression safety)" +assert_eq "." "$(discover single-root-python python)" "single-root/python" + +echo "==> Unit: monorepo-python-js — python resolves to api, javascript resolves to frontend" +assert_eq "api" "$(discover monorepo-python-js python)" "monorepo/python" +assert_eq "frontend" "$(discover monorepo-python-js javascript)" "monorepo/javascript" + +echo "==> Unit: multi-root-python — two roots for one language" +assert_eq "$(printf 'services/a\nservices/b')" "$(discover multi-root-python python)" "multi-root/python" + +echo "==> Unit: monorepo-with-override — projects: override wins over autodetection" +assert_eq "custom-py-dir" "$(discover monorepo-with-override python)" "override/python" + +echo "==> Unit: declared-lang-no-manifest — falls back to '.' rather than being skipped" +assert_eq "." "$(discover declared-lang-no-manifest python)" "no-manifest/python" + +echo "==> Integration: make _lint on monorepo-python-js — cwd + local config resolution" +LINT_SUMMARY=$(run_target monorepo-python-js lint) +assert_eq "pass" "$(printf '%s' "$LINT_SUMMARY" | jq -r '.status')" "lint/status" +assert_eq '["python:api","javascript:frontend"]' "$(printf '%s' "$LINT_SUMMARY" | jq -c '.languages')" "lint/languages-tagged-by-root" + +echo "==> Integration: make _format on monorepo-python-js — cwd-scoped format check" +FORMAT_SUMMARY=$(run_target monorepo-python-js format) +assert_eq "pass" "$(printf '%s' "$FORMAT_SUMMARY" | jq -r '.status')" "format/status" +assert_eq '["python:api","javascript:frontend"]' "$(printf '%s' "$FORMAT_SUMMARY" | jq -c '.languages')" "format/languages-tagged-by-root" + +echo "==> Integration: make _fix on monorepo-python-js — cwd-scoped autofix" +FIX_SUMMARY=$(run_target monorepo-python-js fix) +assert_eq "pass" "$(printf '%s' "$FIX_SUMMARY" | jq -r '.status')" "fix/status" +assert_eq '["python:api","javascript:frontend"]' "$(printf '%s' "$FIX_SUMMARY" | jq -c '.languages')" "fix/languages-tagged-by-root" + +echo "==> Integration: make _security on monorepo-python-js — per-root failure/skip tagging" +SECURITY_SUMMARY=$(run_target monorepo-python-js security) +assert_eq "fail" "$(printf '%s' "$SECURITY_SUMMARY" | jq -r '.status')" "security/status" +assert_eq '["python:api:bandit"]' "$(printf '%s' "$SECURITY_SUMMARY" | jq -c '.failed')" "security/failed-tagged-by-root" +assert_eq '["javascript:frontend"]' "$(printf '%s' "$SECURITY_SUMMARY" | jq -c '.skipped')" "security/skipped-tagged-by-root" + +echo "==> Integration: make _test on monorepo-python-js — pytest cwd + vitest @ alias resolution" +TEST_WS="$(workspace_for monorepo-python-js)" +TEST_OUT=$(docker run --rm \ + -e DEVRAIL_LOG_FORMAT=json \ + -v "${TEST_WS}:/workspace" \ + -v "${REPO_ROOT}/Makefile:/workspace/Makefile:ro" \ + -w /workspace \ + "$IMAGE" \ + make _test 2>&1) || true +TEST_SUMMARY=$(printf '%s\n' "$TEST_OUT" | grep -o '{"target":"test".*}' | tail -1) || true +assert_eq "pass" "$(printf '%s' "$TEST_SUMMARY" | jq -r '.status')" "test/status" +if printf '%s\n' "$TEST_OUT" | grep -q "src/__tests__/smoke.test.ts"; then + echo "PASS [test/vitest-ran]" + PASS=$((PASS + 1)) +else + echo "FAIL [test/vitest-ran]: vitest output for smoke.test.ts not found" >&2 + FAIL=$((FAIL + 1)) +fi + +echo "==> Integration: single-root-python — byte-identical unqualified tags across lint/format/fix/security (AC 4)" +SINGLE_LINT_SUMMARY=$(run_target single-root-python lint) +assert_eq '["python"]' "$(printf '%s' "$SINGLE_LINT_SUMMARY" | jq -c '.languages')" "single-root/lint-languages-unqualified" + +SINGLE_FORMAT_SUMMARY=$(run_target single-root-python format) +assert_eq '["python"]' "$(printf '%s' "$SINGLE_FORMAT_SUMMARY" | jq -c '.languages')" "single-root/format-languages-unqualified" + +SINGLE_SECURITY_SUMMARY=$(run_target single-root-python security) +assert_eq '["python:bandit"]' "$(printf '%s' "$SINGLE_SECURITY_SUMMARY" | jq -c '.failed')" "single-root/security-failed-unqualified" + +echo "==> Unit (Story 15.3): go-monorepo/rust-monorepo — go/rust resolve to services/api" +assert_eq "services/api" "$(discover go-monorepo go)" "go-monorepo/go" +assert_eq "services/api" "$(discover rust-monorepo rust)" "rust-monorepo/rust" + +echo "==> Unit (Story 15.3): go-single-root/rust-single-root — resolve to '.' (regression safety)" +assert_eq "." "$(discover go-single-root go)" "go-single-root/go" +assert_eq "." "$(discover rust-single-root rust)" "rust-single-root/rust" + +echo "==> Integration (Story 15.3): make _lint/_test on go-monorepo — fixes the reproduced 'directory prefix . does not contain main module' failure" +GO_LINT_SUMMARY=$(run_target go-monorepo lint) +assert_eq "pass" "$(printf '%s' "$GO_LINT_SUMMARY" | jq -r '.status')" "go-monorepo/lint-status" +assert_eq '["go:services/api"]' "$(printf '%s' "$GO_LINT_SUMMARY" | jq -c '.languages')" "go-monorepo/lint-languages-tagged-by-root" +GO_TEST_SUMMARY=$(run_target go-monorepo test) +assert_eq "pass" "$(printf '%s' "$GO_TEST_SUMMARY" | jq -r '.status')" "go-monorepo/test-status" +assert_eq '["go:services/api"]' "$(printf '%s' "$GO_TEST_SUMMARY" | jq -c '.languages')" "go-monorepo/test-languages-tagged-by-root" + +echo "==> Integration (Story 15.3): make _lint/_test on rust-monorepo — fixes the reproduced 'could not find Cargo.toml' failure" +RUST_LINT_SUMMARY=$(run_target rust-monorepo lint) +assert_eq "pass" "$(printf '%s' "$RUST_LINT_SUMMARY" | jq -r '.status')" "rust-monorepo/lint-status" +assert_eq '["rust:services/api"]' "$(printf '%s' "$RUST_LINT_SUMMARY" | jq -c '.languages')" "rust-monorepo/lint-languages-tagged-by-root" +RUST_TEST_SUMMARY=$(run_target rust-monorepo test) +assert_eq "pass" "$(printf '%s' "$RUST_TEST_SUMMARY" | jq -r '.status')" "rust-monorepo/test-status" +assert_eq '["rust:services/api"]' "$(printf '%s' "$RUST_TEST_SUMMARY" | jq -c '.languages')" "rust-monorepo/test-languages-tagged-by-root" + +echo "==> Integration (Story 15.3): go-single-root/rust-single-root — byte-identical unqualified tags (AC 4)" +GO_SINGLE_LINT_SUMMARY=$(run_target go-single-root lint) +assert_eq '["go"]' "$(printf '%s' "$GO_SINGLE_LINT_SUMMARY" | jq -c '.languages')" "go-single-root/lint-languages-unqualified" +RUST_SINGLE_LINT_SUMMARY=$(run_target rust-single-root lint) +assert_eq '["rust"]' "$(printf '%s' "$RUST_SINGLE_LINT_SUMMARY" | jq -c '.languages')" "rust-single-root/lint-languages-unqualified" + +echo "==> Integration (Story 15.3): go-multi-root/rust-multi-root — two roots, run and tagged independently (AC 7 — code-review finding, was untested)" +GO_MULTI_TEST_SUMMARY=$(run_target go-multi-root test) +assert_eq "pass" "$(printf '%s' "$GO_MULTI_TEST_SUMMARY" | jq -r '.status')" "go-multi-root/test-status" +assert_eq '["go:services/a","go:services/b"]' "$(printf '%s' "$GO_MULTI_TEST_SUMMARY" | jq -c '.languages')" "go-multi-root/test-languages-tagged-per-root" +RUST_MULTI_TEST_SUMMARY=$(run_target rust-multi-root test) +assert_eq "pass" "$(printf '%s' "$RUST_MULTI_TEST_SUMMARY" | jq -r '.status')" "rust-multi-root/test-status" +assert_eq '["rust:services/a","rust:services/b"]' "$(printf '%s' "$RUST_MULTI_TEST_SUMMARY" | jq -c '.languages')" "rust-multi-root/test-languages-tagged-per-root" + +echo "==> Integration (Story 15.3): go-monorepo/rust-monorepo — _format/_fix/_security (code-review finding: only _lint/_test were covered)" +GO_FORMAT_SUMMARY=$(run_target go-monorepo format) +assert_eq "pass" "$(printf '%s' "$GO_FORMAT_SUMMARY" | jq -r '.status')" "go-monorepo/format-status" +GO_FIX_SUMMARY=$(run_target go-monorepo fix) +assert_eq "pass" "$(printf '%s' "$GO_FIX_SUMMARY" | jq -r '.status')" "go-monorepo/fix-status" +GO_SECURITY_SUMMARY=$(run_target go-monorepo security) +assert_eq "skip" "$(printf '%s' "$GO_SECURITY_SUMMARY" | jq -r '.status')" "go-monorepo/security-status" +assert_eq '["go:services/api"]' "$(printf '%s' "$GO_SECURITY_SUMMARY" | jq -c '.skipped')" "go-monorepo/security-skipped-tagged-by-root" + +RUST_FORMAT_SUMMARY=$(run_target rust-monorepo format) +assert_eq "pass" "$(printf '%s' "$RUST_FORMAT_SUMMARY" | jq -r '.status')" "rust-monorepo/format-status" +RUST_FIX_SUMMARY=$(run_target rust-monorepo fix) +assert_eq "pass" "$(printf '%s' "$RUST_FIX_SUMMARY" | jq -r '.status')" "rust-monorepo/fix-status" +RUST_SECURITY_SUMMARY=$(run_target rust-monorepo security) +assert_eq "pass" "$(printf '%s' "$RUST_SECURITY_SUMMARY" | jq -r '.status')" "rust-monorepo/security-status" +assert_eq '["rust:services/api"]' "$(printf '%s' "$RUST_SECURITY_SUMMARY" | jq -c '.languages')" "rust-monorepo/security-languages-tagged-by-root" + +echo "" +echo "===================================" +echo "Results: ${PASS} passed, ${FAIL} failed" +echo "===================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi diff --git a/tests/test-python.sh b/tests/test-python.sh index 0ae4825..ce11ac3 100644 --- a/tests/test-python.sh +++ b/tests/test-python.sh @@ -18,7 +18,7 @@ source "${DEVRAIL_LIB}/log.sh" if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then log_info "test-python.sh — Validate Python tooling installation" log_info "Usage: bash tests/test-python.sh [--help]" - log_info "Checks: ruff, bandit, semgrep, pytest, mypy" + log_info "Checks: ruff, bandit, semgrep, pytest, mypy, uv" exit 0 fi @@ -52,6 +52,7 @@ check_tool "bandit" "--version" check_tool "semgrep" "--version" check_tool "pytest" "--version" check_tool "mypy" "--version" +check_tool "uv" "--version" if [[ "${FAILURES}" -gt 0 ]]; then log_error "Python tooling validation failed: ${FAILURES} tool(s) missing or broken" diff --git a/tests/test-test-services.sh b/tests/test-test-services.sh new file mode 100644 index 0000000..e990796 --- /dev/null +++ b/tests/test-test-services.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +# tests/test-test-services.sh — Validate ephemeral test.services orchestration (Story 15.4) +# +# Verifies, against checked-in fixtures under tests/fixtures/: +# 1. postgres:16 alone — real SQL query through the injected DATABASE_URL +# 2. redis:7 alone — real SET/GET through the injected REDIS_URL +# 3. both together — one shared network, both env vars injected +# 4. no test.services declared — make test is unaffected (regression) +# 5. docker_network + test.services both set — fails fast with a clear +# mutual-exclusion error, nothing gets started +# 6. an unsupported service entry (mysql:8) — fails fast with a clear +# error naming it, nothing gets started +# 7. teardown actually happens — checked via `docker ps`/`docker network +# ls` after each service-starting case, not just by the trap code +# existing +# 8. a mid-flight SIGKILL leaves orphaned resources (a shell trap cannot +# intercept SIGKILL), and the *next* `make test` run detects and +# cleans up that stale state before starting fresh +# +# Unlike tests/test-project-discover.sh / tests/test-dependency-install.sh, +# which run `make _test` (an INTERNAL target) inside a bind-mounted +# container, this script runs the PUBLIC `make test` target directly on +# the HOST shell — `_test-services-up` is host-side orchestration (it +# calls `docker network create`/`docker run` itself), so it must be +# invoked the way a real consumer would invoke it: `cd && make +# test`, not wrapped in another docker run. +# +# Requires Docker-in-Docker capability and network egress to pull +# postgres:16/redis:7 if not already cached — the same requirement +# tests/test-dependency-install.sh already documents for PyPI/npm. +# +# Usage: bash tests/test-test-services.sh +# Env: +# DEVRAIL_IMAGE override image name (default: ghcr.io/devrail-dev/dev-toolchain) +# DEVRAIL_TAG override image tag (default: local) + +set -euo pipefail + +IMAGE_NAME="${DEVRAIL_IMAGE:-ghcr.io/devrail-dev/dev-toolchain}" +IMAGE_TAG="${DEVRAIL_TAG:-local}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FIXTURES="${REPO_ROOT}/tests/fixtures" +WORKDIR="$(mktemp -d)" + +cleanup() { + # Belt-and-braces: remove any test-services resources this run's own + # fixtures might have left behind (should already be none if teardown + # worked, but don't leave orphans behind from a script-level failure). + docker ps -a --filter "name=devrail-test-" --format '{{.Names}}' 2>/dev/null | xargs -r docker rm -f >/dev/null 2>&1 || true + docker network ls --filter "name=devrail-test-" --format '{{.Name}}' 2>/dev/null | xargs -r -n1 docker network rm >/dev/null 2>&1 || true + if [ -n "${WORKDIR:-}" ] && [ -d "$WORKDIR" ]; then + docker run --rm -v "$WORKDIR:/cleanup" "${IMAGE_NAME}:${IMAGE_TAG}" \ + sh -c 'rm -rf /cleanup/* /cleanup/.[!.]* 2>/dev/null || true' >/dev/null 2>&1 || true + rmdir "$WORKDIR" 2>/dev/null || rm -rf "$WORKDIR" 2>/dev/null || true + fi +} +trap cleanup EXIT + +PASS=0 +FAIL=0 + +assert_eq() { + local expected="$1" actual="$2" context="$3" + if [ "$expected" = "$actual" ]; then + echo "PASS [$context]" + PASS=$((PASS + 1)) + else + echo "FAIL [$context]: expected '$expected', got '$actual'" >&2 + FAIL=$((FAIL + 1)) + fi +} + +assert_true() { + local condition="$1" context="$2" + if [ "$condition" = "true" ]; then + echo "PASS [$context]" + PASS=$((PASS + 1)) + else + echo "FAIL [$context]" >&2 + FAIL=$((FAIL + 1)) + fi +} + +# workspace_for FIXTURE [LABEL] -> a fresh disposable copy of the fixture +# + the real Makefile, at a path unique to this call — via `mktemp -d`, +# not a hand-rolled counter. An incrementing global counter was tried +# first and silently didn't work: every call here is invoked as +# `X="$(workspace_for ...)"`, and command substitution always forks a +# subshell in bash, so the counter's increment never escaped back to the +# caller — every call saw the counter at its initial value and collided +# on the same destination name. `cp -R src dest` then nests src *inside* +# dest instead of overlaying it once dest already exists from a prior +# call, corrupting every call after the first (caught by hand: the +# second/third calls' `.devrail.yml` overwrites landed on the right path, +# but leftover root-owned `.pytest_cache`/`__pycache__` from the *first* +# call's `make test` run made the later calls' `rm -rf` of the reused +# path fail with permission errors). +workspace_for() { + local fixture="$1" + local label="${2:-${fixture}}" + local dest + dest="$(mktemp -d "${WORKDIR}/${label}-XXXXXX")" + rmdir "$dest" # mktemp -d creates it; cp -R needs it absent to copy INTO it, not nest under it + cp -R "${FIXTURES}/${fixture}" "$dest" + cp "${REPO_ROOT}/Makefile" "${dest}/Makefile" + printf '%s' "$dest" +} + +# no_test_services_resources -> "true" if no devrail-test-* container or +# network exists on the host right now. +no_test_services_resources() { + local containers networks + containers="$(docker ps -a --filter "name=devrail-test-" --format '{{.Names}}')" + networks="$(docker network ls --filter "name=devrail-test-" --format '{{.Name}}')" + if [ -z "$containers" ] && [ -z "$networks" ]; then + echo "true" + else + echo "false" + fi +} + +# run_make_test WORKSPACE LOGFILE -> prints the real exit code of `make +# test` run from WORKSPACE, output captured to LOGFILE. Uses an explicit +# if/else to capture the exit code — under `set -e`, a bare command +# followed by `rc=$?` aborts the script on failure before `rc=$?` ever +# runs (confirmed the hard way while writing this script: the first +# version used exactly that pattern and silently died mid-suite on the +# first non-zero exit, the same class of bug Story 15.2's review caught +# in lib/dependency-install.sh's `local rc=$?` after a bare `if...fi`). +run_make_test() { + local ws="$1" logfile="$2" rc + if (cd "$ws" && DEVRAIL_IMAGE="$IMAGE_NAME" DEVRAIL_TAG="$IMAGE_TAG" make test >"$logfile" 2>&1); then + rc=0 + else + rc=$? + fi + # On failure, dump the captured output to stderr (not stdout — this + # function's stdout is captured via $(...) as the exit code) so a CI + # failure shows the real error instead of just a PASS/FAIL summary. + if [ "$rc" -ne 0 ]; then + echo "--- make test output (${logfile}), exit ${rc} ---" >&2 + cat "$logfile" >&2 + echo "--- end output ---" >&2 + fi + echo "$rc" +} + +echo "==> postgres:16 alone — real query through the injected DATABASE_URL" +PG_WS="$(workspace_for test-services-pg-redis)" +cat >"${PG_WS}/.devrail.yml" <<'EOF' +languages: + - python +test: + services: + - postgres:16 +EOF +cat >"${PG_WS}/requirements.txt" <<'EOF' +psycopg2-binary==2.9.10 +EOF +cat >"${PG_WS}/tests/test_services.py" <<'EOF' +import os +import psycopg2 + + +def test_postgres_is_reachable_and_queryable(): + conn = psycopg2.connect(os.environ["DATABASE_URL"]) + try: + with conn.cursor() as cur: + cur.execute("SELECT 1;") + assert cur.fetchone() == (1,) + finally: + conn.close() +EOF +PG_EXIT="$(run_make_test "$PG_WS" "${WORKDIR}/pg.log")" +assert_eq "0" "$PG_EXIT" "postgres-alone/exit-code" +assert_true "$(no_test_services_resources)" "postgres-alone/teardown-clean" + +echo "==> redis:7 alone — real SET/GET through the injected REDIS_URL" +REDIS_WS="$(workspace_for test-services-pg-redis)" +cat >"${REDIS_WS}/.devrail.yml" <<'EOF' +languages: + - python +test: + services: + - redis:7 +EOF +cat >"${REDIS_WS}/requirements.txt" <<'EOF' +redis==5.2.1 +EOF +cat >"${REDIS_WS}/tests/test_services.py" <<'EOF' +import os +import redis + + +def test_redis_is_reachable_and_usable(): + client = redis.from_url(os.environ["REDIS_URL"]) + client.set("story-15-4", "works") + assert client.get("story-15-4") == b"works" +EOF +REDIS_EXIT="$(run_make_test "$REDIS_WS" "${WORKDIR}/redis.log")" +assert_eq "0" "$REDIS_EXIT" "redis-alone/exit-code" +assert_true "$(no_test_services_resources)" "redis-alone/teardown-clean" + +echo "==> postgres + redis together — one network, both env vars injected" +BOTH_WS="$(workspace_for test-services-pg-redis)" +BOTH_EXIT="$(run_make_test "$BOTH_WS" "${WORKDIR}/both.log")" +assert_eq "0" "$BOTH_EXIT" "both/exit-code" +assert_true "$(no_test_services_resources)" "both/teardown-clean" + +echo "==> no test.services declared — make test unaffected (regression)" +NOOP_WS="${WORKDIR}/single-root-python" +cp -R "${FIXTURES}/single-root-python" "$NOOP_WS" +cp "${REPO_ROOT}/Makefile" "${NOOP_WS}/Makefile" +NOOP_EXIT="$(run_make_test "$NOOP_WS" "${WORKDIR}/noop.log")" +assert_eq "0" "$NOOP_EXIT" "no-services/exit-code" +NOOP_OUT="$(cat "${WORKDIR}/noop.log")" +NOOP_SUMMARY=$(printf '%s\n' "$NOOP_OUT" | grep -o '{"target":"test".*}' | tail -1) || true +assert_eq "pass" "$(printf '%s' "$NOOP_SUMMARY" | jq -r '.status')" "no-services/status" +# Note: _test-services-host-bin's extraction step logs unconditionally +# (it fetches the script itself, before the script can even check whether +# services are declared) — that's expected and fine, extraction is cheap +# and cached. What must NOT happen is actual orchestration: no network +# created, no service container started. +if printf '%s\n' "$NOOP_OUT" | grep -qE "created ephemeral test-services network|starting test service"; then + echo "FAIL [no-services/no-orchestration-attempted]: a service network/container was started when no services were declared" >&2 + FAIL=$((FAIL + 1)) +else + echo "PASS [no-services/no-orchestration-attempted]" + PASS=$((PASS + 1)) +fi + +echo "==> docker_network + test.services both set — fails fast, nothing started" +MUTEX_WS="$(workspace_for test-services-mutex)" +MUTEX_EXIT=0 +(cd "$MUTEX_WS" && DEVRAIL_IMAGE="$IMAGE_NAME" DEVRAIL_TAG="$IMAGE_TAG" make test >"${WORKDIR}/mutex.log" 2>&1) || MUTEX_EXIT=$? +assert_eq "2" "$MUTEX_EXIT" "mutex/exit-code" +if grep -q "cannot both be set" "${WORKDIR}/mutex.log"; then + echo "PASS [mutex/clear-error-message]" + PASS=$((PASS + 1)) +else + echo "FAIL [mutex/clear-error-message]: expected a 'cannot both be set' error" >&2 + FAIL=$((FAIL + 1)) +fi +assert_true "$(no_test_services_resources)" "mutex/nothing-started" + +echo "==> unsupported service entry (mysql:8) — fails fast, nothing started" +UNSUPPORTED_WS="$(workspace_for test-services-unsupported)" +UNSUPPORTED_EXIT=0 +(cd "$UNSUPPORTED_WS" && DEVRAIL_IMAGE="$IMAGE_NAME" DEVRAIL_TAG="$IMAGE_TAG" make test >"${WORKDIR}/unsupported.log" 2>&1) || UNSUPPORTED_EXIT=$? +assert_eq "2" "$UNSUPPORTED_EXIT" "unsupported/exit-code" +if grep -q "unsupported test.services entry 'mysql:8'" "${WORKDIR}/unsupported.log"; then + echo "PASS [unsupported/clear-error-message]" + PASS=$((PASS + 1)) +else + echo "FAIL [unsupported/clear-error-message]: expected an error naming 'mysql:8'" >&2 + FAIL=$((FAIL + 1)) +fi +assert_true "$(no_test_services_resources)" "unsupported/nothing-started" + +echo "==> mid-flight SIGKILL leaves orphaned resources; the next run detects and cleans them up" +KILL_WS="$(workspace_for test-services-pg-redis)" +(cd "$KILL_WS" && DEVRAIL_IMAGE="$IMAGE_NAME" DEVRAIL_TAG="$IMAGE_TAG" make test >"${WORKDIR}/kill1.log" 2>&1) & +KILL_PID=$! +# Wait for actual evidence a service container exists, not a fixed sleep — +# a fixed sleep (this used `sleep 3`) is calibrated to one machine's Docker +# overhead (host-bin extraction into a brand-new, cache-empty KILL_WS: a +# docker create + 2 docker cp + docker rm round trip, then network create + +# container start) and goes flaky the moment CI's runner is slower or +# faster than whatever machine picked the number (caught for real: this +# passed locally every time but failed in GitHub Actions CI, where the +# kill fired before any devrail-test-* resource existed yet — killing +# during the extraction/build phase leaves nothing to orphan, so the very +# assertion this case exists to prove never got a chance to be true). +elapsed=0 +while [ "$(no_test_services_resources)" = "true" ] && [ "$elapsed" -lt 60 ]; do + sleep 1 + elapsed=$((elapsed + 1)) +done +# Kill the backgrounded `make test` process itself, not its process +# group — a non-interactive script doesn't get a separate pgid per +# background job, so a group-kill here would take out this script too +# (confirmed the hard way: the whole test suite died mid-run the first +# time this used `kill -- -$PGID`). Killing just the PID is also the more +# realistic simulation: a docker container already started with `-d` is +# detached and keeps running even after its parent `make`/script process +# is gone, which is exactly the orphan scenario AC 8 needs to reproduce. +kill -9 "$KILL_PID" 2>/dev/null || true +sleep 1 +assert_true "$([ "$(no_test_services_resources)" = "false" ] && echo true || echo false)" "sigkill/orphan-actually-left-behind" + +KILL_RERUN_EXIT="$(run_make_test "$KILL_WS" "${WORKDIR}/kill2.log")" +assert_eq "0" "$KILL_RERUN_EXIT" "sigkill/rerun-succeeds" +if grep -q "leftover test-services state" "${WORKDIR}/kill2.log"; then + echo "PASS [sigkill/stale-state-detected-and-cleaned]" + PASS=$((PASS + 1)) +else + echo "FAIL [sigkill/stale-state-detected-and-cleaned]: expected the rerun to log a leftover-state cleanup" >&2 + FAIL=$((FAIL + 1)) +fi +assert_true "$(no_test_services_resources)" "sigkill/final-teardown-clean" + +echo "" +echo "===================================" +echo "Results: ${PASS} passed, ${FAIL} failed" +echo "===================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi