diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0128f56..f1ae012 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,9 +34,45 @@ jobs: - name: Run Tests run: cargo test + e2e-fpm: + name: e2e tests - linux + needs: tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Install runtime deps + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends netcat-openbsd expect libfcgi-bin + + - name: Build pvm (release) + run: cargo build --release + + - name: Run E2E suite + env: + PVM_BIN: ${{ github.workspace }}/target/release/pvm + PVM_VERSION_MAJOR_MINOR: '8.5' + run: bash tests/e2e/run.sh + + - name: Upload fpm logs on failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: fpm-logs + path: | + /tmp/fpm.stdout + /tmp/fpm.stderr + if-no-files-found: ignore + release: name: Semantic Release - needs: tests + needs: [tests, e2e-fpm] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' permissions: diff --git a/README.md b/README.md index ec7f169..ae974ff 100644 --- a/README.md +++ b/README.md @@ -77,3 +77,170 @@ pvm init ### Auto-Switching If you run `pvm init` or manually create a `.php-version` file in a project directory containing `8.3`, PVM will automatically switch to your best local `8.3.x` patch when you `cd` into that folder. The `cd` hook is installed via `pvm env` (Bash, Zsh, or Fish — auto-detected from `$SHELL`). + +## Packages + +Each PHP version can ship up to three binaries; you pick which during `pvm install` via the MultiSelect prompt. All land under `$PVM_DIR/versions//bin/`. Upstream reference for all three SAPIs: [static-php.dev — SAPI Reference](https://static-php.dev/en/guide/sapi-reference.html). + +| Package | Binary | What it is | +|---------|--------|------------| +| `cli` (default) | `php` | Standard command-line PHP — runs scripts, REPL via `php -a`, drives Composer. See [SAPI Reference: CLI](https://static-php.dev/en/guide/sapi-reference.html#cli). | +| `fpm` | `php-fpm` | FastCGI Process Manager for serving PHP behind nginx/Caddy/Apache. Setup details in the next section + [SAPI Reference: FPM](https://static-php.dev/en/guide/sapi-reference.html#fpm). | +| `micro` | `micro.sfx` | [phpmicro](https://github.com/easysoft/phpmicro) self-contained executable stub — concat with a `.php` or `.phar` to ship a single-file PHP app. Combining requires the upstream [`spc`](https://static-php.dev/en/guide/getting-started.html) toolchain (`spc micro:combine app.phar --output=app`); pvm only delivers the stub. See [SAPI Reference: Micro](https://static-php.dev/en/guide/sapi-reference.html#micro). | + +After `pvm use `, every selected binary is on `$PATH` (CLI as `php`, FPM as `php-fpm`); `micro.sfx` stays at its absolute path since it's a build artifact, not something you invoke directly. + +## Running PHP-FPM + +> Upstream reference: [static-php.dev — SAPI Reference: FPM](https://static-php.dev/en/guide/sapi-reference.html#fpm) documents the binary's CLI flags (`-y`, `-c`, `-t`), a minimal `php-fpm.conf`, and an nginx FastCGI block. The guide below extends that with service wiring (systemd / launchd) and pvm-specific paths. + +PVM downloads a static `php-fpm` binary alongside `php` when you tick the `fpm` package during `pvm install`. The static-php-cli tarball ships only the binary — no `php-fpm.conf`, no pool files, no init script — so you wire those up yourself. The binary lives next to the CLI at: + +```text +$PVM_DIR/versions//bin/php-fpm +``` + +`$PVM_DIR` defaults to `~/.local/share/pvm`. After running `pvm use 8.4` it is also on `$PATH` as plain `php-fpm`. + +### 1. Install the fpm package + +```bash +pvm install 8.4 +# When the MultiSelect prompt appears, tick "fpm" (and "cli" if you want both). +pvm use 8.4 +php-fpm -v # confirm it resolves to the pvm-managed binary +which php-fpm # → ~/.local/share/pvm/versions/8.4.x/bin/php-fpm +``` + +### 2. Create a minimal config + +Put these under `~/.config/php-fpm/` (any path works — the binary takes `-y` and `-c`): + +`~/.config/php-fpm/php-fpm.conf`: + +```ini +[global] +pid = /tmp/php-fpm.pid +error_log = /tmp/php-fpm.log +daemonize = no + +include = /home/YOU/.config/php-fpm/pool.d/*.conf +``` + +`~/.config/php-fpm/pool.d/www.conf`: + +```ini +[www] +user = YOU +group = YOU +listen = 127.0.0.1:9000 +; or a unix socket: +; listen = /tmp/php-fpm-www.sock +; listen.owner = YOU +; listen.group = YOU +; listen.mode = 0660 + +pm = dynamic +pm.max_children = 5 +pm.start_servers = 2 +pm.min_spare_servers = 1 +pm.max_spare_servers = 3 + +catch_workers_output = yes +clear_env = no +``` + +Replace `YOU` with your username (`whoami`). + +### 3. Run it in the foreground + +```bash +# Validate config first +php-fpm -y ~/.config/php-fpm/php-fpm.conf -t + +# Foreground run, logs to stdout +php-fpm -y ~/.config/php-fpm/php-fpm.conf -F + +# With a custom php.ini (the static binary has no compiled-in ini path) +php-fpm -c ~/.config/php-fpm/php.ini -y ~/.config/php-fpm/php-fpm.conf -F +``` + +Flag summary (matches upstream [SAPI Reference: FPM](https://static-php.dev/en/guide/sapi-reference.html#fpm)): + +- `-y ` — `php-fpm.conf` path (required, no default for static builds) +- `-c ` — `php.ini` path (optional; without it, fpm runs with hard-coded defaults) +- `-t` — validate config and exit +- `-F` — stay in foreground (don't fork to daemon) +- `-v` — print version +- `-m` — list compiled-in extensions + +### 4. Run it as a service + +**systemd (Linux, user unit)** — `~/.config/systemd/user/php-fpm.service`: + +```ini +[Unit] +Description=PHP-FPM (managed by pvm) +After=network.target + +[Service] +Type=simple +ExecStart=%h/.local/share/pvm/versions/8.4.18/bin/php-fpm -y %h/.config/php-fpm/php-fpm.conf -F +Restart=on-failure + +[Install] +WantedBy=default.target +``` + +```bash +systemctl --user daemon-reload +systemctl --user enable --now php-fpm +journalctl --user -u php-fpm -f +``` + +Pin the full semver in `ExecStart` (e.g. `8.4.18`) — symlinking to `versions/8.4` is not maintained by pvm, so a future `pvm install 8.4` that resolves to `8.4.19` will not move the service. + +**launchd (macOS)** — `~/Library/LaunchAgents/dev.pvm.php-fpm.plist`: + +```xml + + + + + Labeldev.pvm.php-fpm + ProgramArguments + + /Users/YOU/.local/share/pvm/versions/8.4.18/bin/php-fpm + -y + /Users/YOU/.config/php-fpm/php-fpm.conf + -F + + RunAtLoad + KeepAlive + StandardOutPath/tmp/php-fpm.out.log + StandardErrorPath/tmp/php-fpm.err.log + + +``` + +```bash +launchctl load ~/Library/LaunchAgents/dev.pvm.php-fpm.plist +``` + +### 5. Hook up nginx + +```nginx +location ~ \.php$ { + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; +} +``` + +### Notes + +- The static binary is self-contained — no system libphp / no extension `.so` files. Run `php-fpm -m` to list the extensions baked into your build. +- Switching the active CLI via `pvm use 8.3` does **not** restart your fpm service; the service runs whichever absolute path you wired into the unit/plist. Bump the path and reload when you upgrade. +- For multiple parallel versions (e.g. 8.3 + 8.4), run two services on different ports/sockets — `pvm` does not multiplex fpm for you. diff --git a/tests/e2e/Dockerfile b/tests/e2e/Dockerfile new file mode 100644 index 0000000..98f3bde --- /dev/null +++ b/tests/e2e/Dockerfile @@ -0,0 +1,28 @@ +# Sandbox image for the pvm e2e suite. +# Build: docker build -t pvm-e2e tests/e2e +# Run: docker run --rm \ +# -v "$(pwd)/tests/e2e:/home/tester/e2e:ro" \ +# -v "$(pwd)/target/release/pvm:/home/tester/pvm:ro" \ +# -e PVM_BIN=/home/tester/pvm \ +# pvm-e2e bash /home/tester/e2e/run.sh +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Runtime deps mirror what release.yml installs: +# curl, ca-certificates — for install.sh fallback + upstream tarball download +# bash — driver/case scripts +# netcat-openbsd — listener readiness check +# procps — pgrep for worker counting +# expect — drives interactive dialoguer prompts +# libfcgi-bin — supplies cgi-fcgi for FastCGI roundtrip cases +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates bash netcat-openbsd procps \ + expect libfcgi-bin \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m -s /bin/bash tester +USER tester +WORKDIR /home/tester +ENV SHELL=/bin/bash +CMD ["/bin/bash"] diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..62b4fd9 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,99 @@ +# pvm e2e tests (Linux) + +Locks the README "Running PHP-FPM" walkthrough and the core `pvm` flows in lockstep with the upstream static-php-cli FPM tarballs. Runs on every PR via `release.yml` job **e2e tests - linux**. + +## Layout + +```text +tests/e2e/ +├── Dockerfile sandbox image (ubuntu:24.04 + expect + libfcgi-bin) +├── run.sh driver — version resolution + per-case execution +├── _lib.sh shared helpers (run_under_expect, fcgi_call, sandbox guard) +├── README.md this file +└── cases/ + ├── 01_install.sh real `pvm install` with interactive MultiSelect + ├── 02_ls.sh `pvm ls` discovers the version + package tags + ├── 03_use_wrapper.sh `pvm use` via shell wrapper switches PATH + ├── 04_current.sh `pvm current` + ├── 05_php_version_hook.sh `.php-version` cd-hook + ├── 06_use_missing.sh missing-version install prompt + decline (#24) + ├── 07_patch_update.sh patch-update detection + ├── 08_fpm_config.sh README php-fpm.conf + pool.d/{www,sock}.conf, `-t` + ├── 09_fpm_run.sh `php-fpm -F` listens on TCP + unix socket + ├── 10_fcgi_tcp.sh FastCGI roundtrip over TCP + ├── 11_fcgi_sock.sh FastCGI roundtrip over unix socket + ├── 12_php_ini_effective.sh `-c php.ini` is effective inside the worker + ├── 13_pid_log.sh pid file + error log written + ├── 14_fpm_shutdown.sh SIGQUIT clean shutdown + └── 15_uninstall.sh `pvm uninstall` removes the version dir +``` + +The driver runs each `cases/NN_*.sh` as a fresh bash subprocess so state from one case cannot mask bugs in the next. (The previous monolith silently passed because pvm's 24h `.update_check_guard` suppressed the patch-update prompt for any case after the first one to use it.) + +## Why the safety check + +`run.sh` mutates `$HOME/.local/share/pvm`, `/tmp/php-fpm.*`, and `~/.config/php-fpm`. Running on a dev machine would clobber whatever real pvm install lives there. So the driver refuses to run unless one of these is true: + +- `/.dockerenv` exists (you're inside a container) +- `GITHUB_ACTIONS=true` (you're on a hosted runner) +- `/proc/1/cgroup` shows a container runtime (docker, containerd, kubepods) +- `PVM_E2E_FORCE=1` is set (manual override at your own risk) + +## Running locally + +Build the sandbox image once (rebuild only when `Dockerfile` changes): + +```bash +docker build -t pvm-e2e tests/e2e +``` + +Build pvm from source: + +```bash +cargo build --release +``` + +Run the full suite: + +```bash +docker run --rm \ + -v "$(pwd)/tests/e2e:/home/tester/e2e:ro" \ + -v "$(pwd)/target/release/pvm:/home/tester/pvm:ro" \ + -e PVM_BIN=/home/tester/pvm \ + pvm-e2e bash /home/tester/e2e/run.sh +``` + +## Useful overrides + +| Env var | Default | Effect | +|---------|---------|--------| +| `PVM_BIN` | _unset_ | Path to a pre-built pvm. Unset → driver runs `install.sh` and pulls the latest GitHub release. | +| `PVM_VERSION_MAJOR_MINOR` | `8.5` | Major.minor line to test. Both `LATEST` and `PREVIOUS` patches must exist upstream. | +| `PVM_E2E_ONLY` | _unset_ | Space-separated case files to run, e.g. `"01_install.sh 07_patch_update.sh"`. Useful for reproducing one failure. | +| `FPM_TCP_ADDR` | `127.0.0.1:9000` | Override the TCP listener if `:9000` is busy. | +| `PVM_E2E_FORCE` | _unset_ | Set to `1` to bypass the sandbox guard. Don't. | + +### Run a single case + +```bash +docker run --rm \ + -v "$(pwd)/tests/e2e:/home/tester/e2e:ro" \ + -v "$(pwd)/target/release/pvm:/home/tester/pvm:ro" \ + -e PVM_BIN=/home/tester/pvm \ + -e PVM_E2E_ONLY="07_patch_update.sh" \ + pvm-e2e bash /home/tester/e2e/run.sh +``` + +Note: cases 09–13 depend on case 08 having written the FPM config and on a running FPM process — running them in isolation will fail unless you also include the cases that set up that state. + +### Test PHP 8.4 instead of 8.5 + +```bash +docker run --rm ... \ + -e PVM_VERSION_MAJOR_MINOR=8.4 \ + pvm-e2e bash /home/tester/e2e/run.sh +``` + +## In CI + +The job lives in `.github/workflows/release.yml` as `e2e tests - linux`, chained `tests` → `e2e tests - linux` → `release`. It builds pvm from source, installs `expect` + `libfcgi-bin` + `netcat-openbsd`, and invokes `tests/e2e/run.sh` directly on the runner — no Docker layer needed because the runner *is* the sandbox (`GITHUB_ACTIONS=true`). diff --git a/tests/e2e/_lib.sh b/tests/e2e/_lib.sh new file mode 100755 index 0000000..73a3f61 --- /dev/null +++ b/tests/e2e/_lib.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Shared helpers for tests/e2e cases. +# Source from a case via: source "$(dirname "$0")/../_lib.sh" +# The driver pre-exports PVM_BIN, PREVIOUS, LATEST, VFILTER, VDIR, etc. + +GREEN='\033[0;32m'; RED='\033[0;31m'; BLUE='\033[0;34m'; YEL='\033[0;33m'; NC='\033[0m' +ok() { echo -e "${GREEN}✓${NC} $*"; } +fail() { echo -e "${RED}✗${NC} $*" >&2; exit 1; } +step() { echo -e "${BLUE}==>${NC} $*"; } +warn() { echo -e "${YEL}!${NC} $*"; } + +# Spawn a script under expect, auto-decline the three known prompts. +# Always exits 0; caller asserts on captured stdout for markers. +run_under_expect() { + local script="$1" + expect < body to fpm via cgi-fcgi and echo the response. +# Args: connect_target (e.g. "127.0.0.1:9000" or "/tmp/sock"), php_body +fcgi_call() { + local connect="$1" + local php_body="$2" + local script_dir + script_dir=$(mktemp -d) + # RETURN trap fires even when set -e in the caller would otherwise abort + # mid-call due to a non-zero cgi-fcgi exit, so the temp dir is always removed. + trap 'rm -rf "$script_dir"' RETURN + echo " "$script_dir/run.php" + SCRIPT_FILENAME="$script_dir/run.php" \ + SCRIPT_NAME=/run.php \ + REQUEST_METHOD=GET \ + QUERY_STRING="" \ + cgi-fcgi -bind -connect "$connect" +} + +# Safety: refuse to mutate the user's local pvm state outside Docker or GitHub Actions. +# Both run.sh and any case sourcing this lib hit $HOME/.local/share/pvm, /tmp, and +# $HOME/.config/php-fpm — running on a dev machine would clobber the user's setup. +e2e_require_sandbox() { + if [[ "${PVM_E2E_FORCE:-}" == "1" ]]; then + warn "PVM_E2E_FORCE=1 — running outside Docker / GitHub Actions on caller's request" + return 0 + fi + if [[ -f /.dockerenv ]]; then + return 0 + fi + if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + return 0 + fi + if grep -qE '(/docker/|containerd|kubepods)' /proc/1/cgroup 2>/dev/null; then + return 0 + fi + fail "tests/e2e mutates \$HOME/.local/share/pvm, /tmp/php-fpm.*, and ~/.config/php-fpm. + Run inside Docker (see tests/e2e/README.md) or GitHub Actions. + Override at your own risk with PVM_E2E_FORCE=1." +} diff --git a/tests/e2e/cases/01_install.sh b/tests/e2e/cases/01_install.sh new file mode 100755 index 0000000..a4b045f --- /dev/null +++ b/tests/e2e/cases/01_install.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Real `pvm install` flow with interactive MultiSelect via expect. +# Toggles `fpm` on top of the default `cli` selection. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "pvm install $PREVIOUS — toggle fpm in MultiSelect" + +expect <` through the shell wrapper switches PATH. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "pvm use $VFILTER via wrapper — PATH switch + which php-fpm" + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +cat > "$WORKDIR/run.sh" <&1 | head -n 1 +EOF +chmod +x "$WORKDIR/run.sh" + +OUT=$(run_under_expect "$WORKDIR/run.sh" 2>&1) +echo "$OUT" + +echo "$OUT" | grep -q "PATH_AFTER_USE=$VDIR" \ + || fail "PATH not switched to $VDIR" +echo "$OUT" | grep -q "WHICH_PHP_FPM=$VDIR/php-fpm" \ + || fail "which php-fpm did not resolve under pvm versions dir" +ok "wrapper switched PATH; php-fpm → $VDIR/php-fpm" diff --git a/tests/e2e/cases/04_current.sh b/tests/e2e/cases/04_current.sh new file mode 100755 index 0000000..fa073da --- /dev/null +++ b/tests/e2e/cases/04_current.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# `pvm current` reports the active version after `pvm use`. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "pvm current — after pvm use" + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +cat > "$WORKDIR/run.sh" </dev/null +pvm current +EOF +chmod +x "$WORKDIR/run.sh" + +OUT=$(run_under_expect "$WORKDIR/run.sh" 2>&1) +echo "$OUT" +echo "$OUT" | grep -q "$PREVIOUS" || fail "pvm current did not show $PREVIOUS" +ok "pvm current → $PREVIOUS" diff --git a/tests/e2e/cases/05_php_version_hook.sh b/tests/e2e/cases/05_php_version_hook.sh new file mode 100755 index 0000000..d4f2fa9 --- /dev/null +++ b/tests/e2e/cases/05_php_version_hook.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# `.php-version` cd-hook auto-switches the active version. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step ".php-version cd-hook" + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +PROJ="$WORKDIR/proj" +mkdir -p "$PROJ" +echo "$VFILTER" > "$PROJ/.php-version" + +cat > "$WORKDIR/run.sh" </dev/null || { echo "_pvm_cd_hook not defined"; exit 1; } +cd '$PROJ' +_pvm_cd_hook +pvm current +EOF +chmod +x "$WORKDIR/run.sh" + +OUT=$(run_under_expect "$WORKDIR/run.sh" 2>&1) +echo "$OUT" +echo "$OUT" | grep -q "$PREVIOUS" \ + || fail ".php-version did not switch to $VFILTER → $PREVIOUS" +ok ".php-version cd-hook switched to $PREVIOUS" diff --git a/tests/e2e/cases/06_use_missing.sh b/tests/e2e/cases/06_use_missing.sh new file mode 100755 index 0000000..c57a352 --- /dev/null +++ b/tests/e2e/cases/06_use_missing.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# `pvm use ` prompts to install (#24); decline cancels cleanly. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "pvm use $MISSING_VER (uninstalled) → install prompt → decline" + +OUT=$(expect <&1 +set timeout 60 +log_user 1 +spawn $PVM_BIN use $MISSING_VER +expect { + -re {is not installed locally.*Do you want to install} { send "n\r" } + timeout { puts "TIMEOUT_MISSING_PROMPT" } + eof { puts "EOF_MISSING_PROMPT" } +} +expect eof +EXPECT_EOF +) +echo "$OUT" +echo "$OUT" | grep -q "is not installed locally" \ + || fail "missing-version install prompt not shown" +echo "$OUT" | grep -q "Operation cancelled" \ + || fail "decline did not cancel cleanly" +ok "missing-version prompt + decline path works (#24)" diff --git a/tests/e2e/cases/07_patch_update.sh b/tests/e2e/cases/07_patch_update.sh new file mode 100755 index 0000000..d4f50ac --- /dev/null +++ b/tests/e2e/cases/07_patch_update.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Patch-update detection: `pvm use ` offers the newer patch. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +if [[ "$LATEST" == "$PREVIOUS" ]]; then + warn "only one ${VFILTER}.x patch upstream — skipping patch-update detection" + exit 0 +fi + +# pvm rate-limits the patch-update check to once per 24h via this guard file; +# clear it so the prompt actually fires when this case runs after another `pvm use`. +rm -f "${PVM_DIR:-$HOME/.local/share/pvm}/.update_check_guard" + +step "pvm use $VFILTER offers $LATEST over $PREVIOUS" + +OUT=$(expect <&1 +set timeout 90 +log_user 1 +spawn $PVM_BIN use $VFILTER +expect { + -re {patch version is available.*Do you want to install} { send "n\r" } + timeout { puts "TIMEOUT_PATCH_PROMPT" } + eof { puts "EOF_PATCH_PROMPT" } +} +expect eof +EXPECT_EOF +) +echo "$OUT" +echo "$OUT" | grep -q "$LATEST" \ + || fail "patch-update prompt did not mention $LATEST" +ok "patch-update prompt offered $PREVIOUS → $LATEST" diff --git a/tests/e2e/cases/08_fpm_config.sh b/tests/e2e/cases/08_fpm_config.sh new file mode 100755 index 0000000..eab7fbd --- /dev/null +++ b/tests/e2e/cases/08_fpm_config.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Write README config (TCP + Unix socket pools) and validate via `php-fpm -t`. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "Write README php-fpm config and validate via -t" + +USER_NAME="$(whoami)" +FPM_TCP_ADDR="${FPM_TCP_ADDR:-127.0.0.1:9000}" +mkdir -p "$HOME/.config/php-fpm/pool.d" + +cat > "$HOME/.config/php-fpm/php-fpm.conf" < "$HOME/.config/php-fpm/pool.d/www.conf" < "$HOME/.config/php-fpm/pool.d/sock.conf" < "$HOME/.config/php-fpm/php.ini" <<'EOF' +memory_limit = 256M +expose_php = Off +EOF + +ok "wrote php-fpm.conf, pool.d/{www,sock}.conf, php.ini" + +if "$VDIR/php-fpm" -y "$HOME/.config/php-fpm/php-fpm.conf" -t; then + ok "php-fpm -t OK" +else + fail "php-fpm -t failed" +fi + +"$VDIR/php-fpm" -v +MOD_COUNT=$("$VDIR/php-fpm" -m | grep -cE '^[a-z]' || true) +ok "php-fpm -m listed $MOD_COUNT modules" diff --git a/tests/e2e/cases/09_fpm_run.sh b/tests/e2e/cases/09_fpm_run.sh new file mode 100755 index 0000000..d0bb825 --- /dev/null +++ b/tests/e2e/cases/09_fpm_run.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Start php-fpm in foreground; verify TCP + unix socket listeners come up. +# Leaves the master running for fcgi roundtrip cases; driver kills on exit. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "Start php-fpm -F; verify listeners" + +rm -f "$FPM_PID_FILE" "$FPM_LOG_FILE" "$FPM_SOCK" + +"$VDIR/php-fpm" \ + -c "$HOME/.config/php-fpm/php.ini" \ + -y "$HOME/.config/php-fpm/php-fpm.conf" \ + -F \ + > /tmp/fpm.stdout 2> /tmp/fpm.stderr & + +FPM_PID=$! +echo "$FPM_PID" > "$E2E_STATE/fpm.pid" + +TCP_HOST="${FPM_TCP_ADDR%:*}" +TCP_PORT="${FPM_TCP_ADDR##*:}" + +for i in $(seq 1 60); do + if nc -z "$TCP_HOST" "$TCP_PORT" 2>/dev/null && [[ -S "$FPM_SOCK" ]]; then + ok "fpm listening on TCP $FPM_TCP_ADDR + unix $FPM_SOCK (after ${i}*100ms)" + break + fi + sleep 0.1 +done + +if ! nc -z "$TCP_HOST" "$TCP_PORT" 2>/dev/null; then + echo "--- stdout ---"; cat /tmp/fpm.stdout || true + echo "--- stderr ---"; cat /tmp/fpm.stderr || true + fail "TCP $FPM_TCP_ADDR not listening" +fi +[[ -S "$FPM_SOCK" ]] || fail "unix socket $FPM_SOCK not created" + +WORKER_COUNT=$(pgrep -P "$FPM_PID" 2>/dev/null | wc -l || true) +if [[ "$WORKER_COUNT" -ge 2 ]]; then + ok "$WORKER_COUNT workers spawned" +else + warn "only $WORKER_COUNT workers detected" +fi diff --git a/tests/e2e/cases/10_fcgi_tcp.sh b/tests/e2e/cases/10_fcgi_tcp.sh new file mode 100755 index 0000000..155bf39 --- /dev/null +++ b/tests/e2e/cases/10_fcgi_tcp.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# FastCGI roundtrip over TCP — execute . +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "FastCGI roundtrip over TCP $FPM_TCP_ADDR" +RESPONSE=$(fcgi_call "$FPM_TCP_ADDR" 'echo "FCGI_OK\n";' 2>&1) +echo "$RESPONSE" +echo "$RESPONSE" | grep -q "FCGI_OK" || fail "TCP roundtrip did not return FCGI_OK" +ok "TCP FastCGI executed PHP" diff --git a/tests/e2e/cases/11_fcgi_sock.sh b/tests/e2e/cases/11_fcgi_sock.sh new file mode 100755 index 0000000..26186d2 --- /dev/null +++ b/tests/e2e/cases/11_fcgi_sock.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# FastCGI roundtrip over Unix socket. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "FastCGI roundtrip over $FPM_SOCK" +RESPONSE=$(fcgi_call "$FPM_SOCK" 'echo "FCGI_OK\n";' 2>&1) +echo "$RESPONSE" +echo "$RESPONSE" | grep -q "FCGI_OK" || fail "unix socket roundtrip failed" +ok "Unix socket FastCGI executed PHP" diff --git a/tests/e2e/cases/12_php_ini_effective.sh b/tests/e2e/cases/12_php_ini_effective.sh new file mode 100755 index 0000000..d92daf8 --- /dev/null +++ b/tests/e2e/cases/12_php_ini_effective.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Custom php.ini via -c flag — assert ini_get values inside the FPM worker. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "-c php.ini effective inside worker (memory_limit, expose_php)" +RESPONSE=$(fcgi_call "$FPM_TCP_ADDR" \ + 'echo "MEM=" . ini_get("memory_limit") . "\n"; echo "EXPOSE=" . (ini_get("expose_php") ? "On" : "Off") . "\n";' \ + 2>&1) +echo "$RESPONSE" +echo "$RESPONSE" | grep -q "MEM=256M" \ + || fail "memory_limit not 256M inside worker — -c flag not effective" +echo "$RESPONSE" | grep -q "EXPOSE=Off" \ + || fail "expose_php not Off inside worker" +ok "php.ini applied inside worker" diff --git a/tests/e2e/cases/13_pid_log.sh b/tests/e2e/cases/13_pid_log.sh new file mode 100755 index 0000000..502c6af --- /dev/null +++ b/tests/e2e/cases/13_pid_log.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Pid file written + error log present. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "pid file + error log" + +[[ -s "$FPM_PID_FILE" ]] || fail "pid file $FPM_PID_FILE not written" +PID_IN_FILE=$(cat "$FPM_PID_FILE") +EXPECTED_PID=$(cat "$E2E_STATE/fpm.pid") +[[ "$PID_IN_FILE" -eq "$EXPECTED_PID" ]] \ + && ok "pid file matches master ($PID_IN_FILE)" \ + || warn "pid file $PID_IN_FILE != master $EXPECTED_PID" + +[[ -s "$FPM_LOG_FILE" ]] && ok "error log $FPM_LOG_FILE has content" \ + || warn "error log empty (ok if startup was quiet)" diff --git a/tests/e2e/cases/14_fpm_shutdown.sh b/tests/e2e/cases/14_fpm_shutdown.sh new file mode 100755 index 0000000..3cffa30 --- /dev/null +++ b/tests/e2e/cases/14_fpm_shutdown.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Send SIGQUIT to the fpm master and wait for clean exit. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "SIGQUIT shutdown" + +if [[ ! -f "$E2E_STATE/fpm.pid" ]]; then + warn "no fpm pid recorded — assuming already stopped" + exit 0 +fi + +FPM_PID=$(cat "$E2E_STATE/fpm.pid") +kill -QUIT "$FPM_PID" 2>/dev/null || true + +for i in $(seq 1 50); do + if ! kill -0 "$FPM_PID" 2>/dev/null; then + ok "fpm master $FPM_PID exited cleanly (after ${i}*100ms)" + rm -f "$E2E_STATE/fpm.pid" + exit 0 + fi + sleep 0.1 +done + +fail "fpm master $FPM_PID still alive after 5s" diff --git a/tests/e2e/cases/15_uninstall.sh b/tests/e2e/cases/15_uninstall.sh new file mode 100755 index 0000000..f66d90a --- /dev/null +++ b/tests/e2e/cases/15_uninstall.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# `pvm uninstall ` removes the version dir and pvm ls drops the entry. +# Driver runs this AFTER fpm has been shut down. +set -euo pipefail +source "$(dirname "$0")/../_lib.sh" + +step "pvm uninstall $PREVIOUS" + +"$PVM_BIN" uninstall "$PREVIOUS" + +if [[ ! -d "${PVM_DIR:-$HOME/.local/share/pvm}/versions/$PREVIOUS" ]]; then + ok "versions/$PREVIOUS directory removed" +else + fail "uninstall left versions/$PREVIOUS in place" +fi + +if "$PVM_BIN" ls 2>&1 | grep -q "$PREVIOUS"; then + fail "pvm ls still shows $PREVIOUS after uninstall" +fi +ok "pvm ls no longer lists $PREVIOUS" diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh new file mode 100755 index 0000000..544a912 --- /dev/null +++ b/tests/e2e/run.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Driver for tests/e2e — runs each cases/NN_*.sh as a fresh bash subprocess +# so state from one case does not leak into the next. +# +# Usage: +# PVM_BIN=/path/to/pvm bash tests/e2e/run.sh # CI: use a pre-built pvm +# bash tests/e2e/run.sh # local: download via install.sh +# +# Optional env: +# PVM_VERSION_MAJOR_MINOR major.minor to test (default: 8.5) +# PVM_E2E_ONLY space-separated case file names to run (e.g. "01_install.sh 02_ls.sh") +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/_lib.sh" + +# Refuse to run on a dev machine — we mutate $HOME/.local/share/pvm and /tmp. +# Override with PVM_E2E_FORCE=1 if you really mean it. +e2e_require_sandbox + +for tool in curl tar nc expect cgi-fcgi pgrep; do + command -v "$tool" >/dev/null 2>&1 \ + || fail "missing required tool: $tool (install: expect libfcgi-bin)" +done + +# --------------------------------------------------------------------------- +# Stage pvm binary +# --------------------------------------------------------------------------- +if [[ -n "${PVM_BIN:-}" ]]; then + [[ -x "$PVM_BIN" ]] || fail "PVM_BIN set but not executable: $PVM_BIN" + : "${PVM_DIR:=$HOME/.local/share/pvm}" + mkdir -p "$PVM_DIR/bin" + cp "$PVM_BIN" "$PVM_DIR/bin/pvm" + PVM_BIN="$PVM_DIR/bin/pvm" + ok "using pre-built pvm at $PVM_BIN" +else + curl -fsSL --connect-timeout 10 --max-time 60 \ + https://raw.githubusercontent.com/WebProject-xyz/php-version-manager/main/install.sh | bash + PVM_BIN="$HOME/.local/share/pvm/bin/pvm" + ok "installed via install.sh" +fi +"$PVM_BIN" --version + +# --------------------------------------------------------------------------- +# Resolve target versions from upstream index +# --------------------------------------------------------------------------- +ARCH="$(uname -m)" +case "$ARCH" in + x86_64|amd64) TGT="linux-x86_64" ;; + aarch64|arm64) TGT="linux-aarch64" ;; + *) fail "unsupported arch $ARCH" ;; +esac + +VFILTER="${PVM_VERSION_MAJOR_MINOR:-8.5}" +ESCAPED_VFILTER="${VFILTER//./\\.}" + +INDEX_JSON=$(curl -fsSL --connect-timeout 10 --max-time 30 \ + "https://dl.static-php.dev/static-php-cli/bulk/?format=json") +ALL_VERS=$(echo "$INDEX_JSON" \ + | grep -oE "\"php-${ESCAPED_VFILTER}\\.[0-9]+-cli-${TGT}\\.tar\\.gz\"" \ + | sed -E "s|\"php-(${ESCAPED_VFILTER}\\.[0-9]+)-cli-${TGT}\\.tar\\.gz\"|\\1|" \ + | sort -V -u) +LATEST=$(echo "$ALL_VERS" | tail -n 1) +PREVIOUS=$(echo "$ALL_VERS" | tail -n 2 | head -n 1) +[[ -n "$LATEST" ]] || fail "no ${VFILTER}.x versions found upstream" +[[ -n "$PREVIOUS" && "$PREVIOUS" != "$LATEST" ]] || PREVIOUS="$LATEST" + +VDIR="$HOME/.local/share/pvm/versions/$PREVIOUS/bin" +MISSING_VER="${VFILTER}.99999" + +ok "latest ${VFILTER}.x = $LATEST / previous = $PREVIOUS" + +# --------------------------------------------------------------------------- +# Shared state dir + fpm config paths (used across cases) +# --------------------------------------------------------------------------- +E2E_STATE=$(mktemp -d) +FPM_PID_FILE=/tmp/php-fpm.pid +FPM_LOG_FILE=/tmp/php-fpm.log +FPM_SOCK=/tmp/php-fpm-www.sock +FPM_TCP_ADDR="${FPM_TCP_ADDR:-127.0.0.1:9000}" + +cleanup() { + if [[ -f "$E2E_STATE/fpm.pid" ]]; then + kill -QUIT "$(cat "$E2E_STATE/fpm.pid")" 2>/dev/null || true + fi + rm -rf "$E2E_STATE" + rm -f "$FPM_SOCK" "$FPM_PID_FILE" "$FPM_LOG_FILE" +} +trap cleanup EXIT + +export PVM_BIN VFILTER LATEST PREVIOUS VDIR MISSING_VER E2E_STATE +export FPM_PID_FILE FPM_LOG_FILE FPM_SOCK FPM_TCP_ADDR + +# --------------------------------------------------------------------------- +# Run each case as a fresh bash subprocess +# --------------------------------------------------------------------------- +CASES=() +if [[ -n "${PVM_E2E_ONLY:-}" ]]; then + for c in $PVM_E2E_ONLY; do + CASES+=("$HERE/cases/$c") + done +else + while IFS= read -r f; do + CASES+=("$f") + done < <(find "$HERE/cases" -maxdepth 1 -name '[0-9][0-9]_*.sh' | sort) +fi + +PASSED=0 +FAILED=0 +for case_file in "${CASES[@]}"; do + case_name=$(basename "$case_file" .sh) + echo + echo -e "${BLUE}── ${case_name} ──${NC}" + if bash "$case_file"; then + PASSED=$((PASSED + 1)) + else + FAILED=$((FAILED + 1)) + echo -e "${RED}✗ ${case_name} failed${NC}" >&2 + # Stop on first failure — later cases depend on earlier state (fpm, install). + echo + echo -e "${RED}aborting: $FAILED failed, $PASSED passed before failure${NC}" >&2 + exit 1 + fi +done + +echo +echo -e "${GREEN}All $PASSED e2e cases passed on Linux.${NC}" +echo "Tested version: $PREVIOUS (latest upstream: $LATEST)" +echo "Pools: TCP 127.0.0.1:9000 + unix $FPM_SOCK"