Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5a15221
test(log-command): add behat context with shared steps
mrrobot47 Oct 19, 2026
21a2139
test(log-command): cover command surface and site resolution
mrrobot47 Oct 19, 2026
513f5fa
test(log-command): cover site logs, type filters and line count
mrrobot47 Oct 19, 2026
f1a54b5
test(log-command): cover cli and global logs
mrrobot47 Oct 19, 2026
c46bf12
test(log-command): add coverage inventory
mrrobot47 Oct 19, 2026
5cd70a8
test(log-command): pass the logical cwd to runs from a site directory
mrrobot47 Oct 19, 2026
f0a4ad6
test(log-command): cover log file name injection and tighten --cli --n
mrrobot47 Oct 19, 2026
d5266e3
test(log-command): tag the --quiet scenario as a known core bug (CORE-3)
mrrobot47 Oct 19, 2026
69269f4
test(log-command): run the LOGCMD-1/2 scenarios as regular ones
mrrobot47 Oct 19, 2026
dca46fc
test(log-command): cover log links leaving the site's log directory (…
mrrobot47 Oct 19, 2026
dffbee1
test(log-command): don't expect the INFO line to be the last ee.log l…
mrrobot47 Oct 19, 2026
64a23fd
ci: run the behat suite on GitHub Actions
mrrobot47 Oct 19, 2026
70b696a
ci: finalize the behat workflow
mrrobot47 Oct 19, 2026
4c15882
ci: re-fetch docker-compose when the first run leaves a broken binary
mrrobot47 Oct 19, 2026
1141a03
test(behat): leave the known-bug scenarios out until their fixes land
mrrobot47 Oct 19, 2026
191668e
test(behat): only remove the phar files this run built
mrrobot47 Oct 19, 2026
6cc8208
test(log): quote the bin/ee path when running from another directory
mrrobot47 Oct 19, 2026
f56b1ea
test(behat): fix the container count step and drop the unused subsite…
mrrobot47 Oct 19, 2026
2d6dfda
docs(log-command): describe coverage gaps without workspace references
mrrobot47 Oct 20, 2026
640a40c
ci(log-command): run the behat lanes on every supported PHP version
mrrobot47 Oct 20, 2026
8e4b8d2
test(log-command): match lists by membership and compare whole multil…
mrrobot47 Oct 20, 2026
d13a3c7
test(log-command): count each expected CSV row once
mrrobot47 Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions .github/workflows/behat.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# Behat suite of an EasyEngine package, run inside a fresh core checkout on a throwaway runner.
name: Behat

on:
pull_request:
push:
branches:
- develop
workflow_dispatch:

permissions:
contents: read

concurrency:
group: behat-${{ github.ref }}
cancel-in-progress: true

env:
CORE_REF: develop
COMPOSE_VERSION: v5.5.1
# Not RCLONE_*: rclone reads those variables as its own flags.
RCLONE_RELEASE: v1.75.1
# No public DNS (Let's Encrypt) or real backup storage on hosted runners.
EXCLUDE_TAGS: '~@le&&~@remote-real'
# Local rclone alias written to EE's rclone-path, for suites with @remote scenarios ('eetest:ee-backups'); empty = no rclone.
EE_RCLONE_PATH: ''
COMPOSE_INTERACTIVE_NO_CLI: 1
RCLONE_CONFIG: /etc/rclone/rclone.conf
EE_BEHAT_DESTRUCTIVE: 1

jobs:
behat:
name: Behat ${{ matrix.lane }} (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
timeout-minutes: 150
strategy:
fail-fast: false
matrix:
php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
lane: [main]
include:
- lane: main
tags: '~@known-bug'
steps:
- name: Check out the package
uses: actions/checkout@v7
with:
path: pkg

- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
tools: composer
extensions: pcntl, curl, sqlite3, zip, dom, mbstring, json

- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config -g cache-files-dir)" >> "$GITHUB_OUTPUT"

- name: Set up Composer caching
uses: actions/cache@v6
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-php${{ matrix.php }}-${{ github.sha }}
restore-keys: ${{ runner.os }}-composer-php${{ matrix.php }}-

- name: Prepare the host
run: |
# The runner image ships web and database servers; EE needs 80/443.
sudo systemctl stop nginx apache2 mysql 2>/dev/null || true
sudo ss -ltnp | grep -E ':(80|443|3306) ' || true
sudo curl -fsSL "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
docker version --format 'docker {{.Server.Version}}'
docker-compose version
openssl version
echo "umask $(umask), under sudo $(sudo sh -c umask)"
# Suites that request the host's public IP use the first of these; EE picks a frontend subnet outside eth0's.
echo "hostname -I: $(hostname -I)"
ip -4 route
if [ -n "$EE_RCLONE_PATH" ]; then
curl -fsSL "https://github.com/rclone/rclone/releases/download/${RCLONE_RELEASE}/rclone-${RCLONE_RELEASE}-linux-amd64.zip" -o "$RUNNER_TEMP/rclone.zip"
unzip -q -j -o "$RUNNER_TEMP/rclone.zip" '*/rclone' -d "$RUNNER_TEMP"
sudo install -m 0755 "$RUNNER_TEMP/rclone" /usr/local/bin/rclone
sudo mkdir -p "$(dirname "$RCLONE_CONFIG")" /var/tmp/ee-rclone
printf '[%s]\ntype = alias\nremote = /var/tmp/ee-rclone\n' "${EE_RCLONE_PATH%%:*}" | sudo tee "$RCLONE_CONFIG" >/dev/null
sudo -E rclone listremotes
fi

- name: Set up EasyEngine core
run: |
git clone -q --depth 1 -b "$CORE_REF" https://github.com/EasyEngine/easyengine.git easyengine
cd easyengine
# All easyengine/* packages on dev-develop (as core CI does), except the one under test: that's this checkout.
php -r '
$pkg = json_decode(file_get_contents("../pkg/composer.json"), true)["name"];
$j = json_decode(file_get_contents("composer.json"), true);
foreach ($j["require"] as $k => $v) if (strpos($k, "easyengine/") === 0) $j["require"][$k] = "dev-develop";
$repos = isset($j["repositories"]) ? $j["repositories"] : [];
array_unshift($repos, ["type" => "path", "url" => "../pkg", "options" => ["symlink" => false, "versions" => [$pkg => "dev-develop"]]]);
$j["repositories"] = $repos;
file_put_contents("composer.json", json_encode($j, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
file_put_contents(getenv("GITHUB_ENV"), "PKG=$pkg\n", FILE_APPEND);'
composer update --prefer-dist --no-progress --no-interaction
composer show "$(jq -r .name ../pkg/composer.json)" | grep -E '^(versions|source|dist) ' || true
rm -rf features
cp -R ../pkg/features features
# Some commands (clone, sync) shell out to `ee` from PATH.
sudo ln -sf "$PWD/bin/ee" /usr/local/bin/ee

- name: Initialise EasyEngine
working-directory: easyengine
run: |
sudo -E bin/ee cli info
# The first non-cli command runs the initial setup (DB, config, image pulls).
sudo -E bin/ee site list || true
# Non-LE commands such as ssl-renew still prompt for le-mail when it's missing.
sudo -E bin/ee config set le-mail behat-ci@example.org
if [ -n "$EE_RCLONE_PATH" ]; then
sudo -E bin/ee config set rclone-path "$EE_RCLONE_PATH"
sudo -E bin/ee config set no-of-backups 3
fi
# The first run swaps the installed compose for 2.27.0 (a first-run docker-compose check), so that's what the suite runs on, as on a fresh install.
# Its download has no --fail: a GitHub error page can replace the binary, so fetch 2.27.0 again then.
if ! docker-compose version; then
sudo curl -fsSL --retry 5 "https://github.com/docker/compose/releases/download/v2.27.0/docker-compose-linux-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
docker-compose version
fi


- name: Run Behat
working-directory: easyengine
shell: 'script -q -e -c "bash {0}"'
# A hung step (e.g. a command stopped on the TTY) should still leave logs.
timeout-minutes: 75
run: |
set -e
mkdir -p "$RUNNER_TEMP/behat/junit"
base="$EXCLUDE_TAGS"
behat=(sudo -E vendor/bin/behat --strict --tags="${base}&&${{ matrix.tags }}"
--format=progress --out=std
--format=junit --out="$RUNNER_TEMP/behat/junit"
--format=pretty --out="$RUNNER_TEMP/behat/pretty.log")
"${behat[@]}"

- name: Collect logs
if: always()
run: |
out="$RUNNER_TEMP/behat"
mkdir -p "$out"
sudo cp /opt/easyengine/logs/ee.log "$out/" 2>/dev/null || echo 'No ee.log.'
sudo stat -c '%a %U:%G %n' /opt/easyengine /opt/easyengine/logs > "$out/dirs.txt" 2>/dev/null || true
sudo docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}' > "$out/containers.txt" || true
sudo chown -R "$USER" "$out"
# One block per failed scenario, from the junit files.
python3 - "$out" <<'PY'
import glob, re, sys, xml.etree.ElementTree as ET
out = sys.argv[1]
lines = []
for f in sorted(glob.glob(out + '/junit/*.xml')):
# Behat copies raw output (docker logs with ANSI escapes) into the junit file; XML 1.0 forbids those bytes.
data = re.sub(rb'[\x00-\x08\x0b\x0c\x0e-\x1f]', b'', open(f, 'rb').read())
for case in ET.fromstring(data).iter('testcase'):
for bad in list(case.findall('failure')) + list(case.findall('error')):
lines.append('### %s :: %s\n%s\n%s\n' % (case.get('classname'), case.get('name'), bad.get('message') or '', bad.text or ''))
open(out + '/failures.txt', 'w').write('\n'.join(lines) or 'No failures.\n')
print('%d failed scenarios' % len(lines))
PY
head -c 20000 "$out/failures.txt"


- name: Upload results
if: always()
uses: actions/upload-artifact@v7
with:
name: behat-${{ matrix.lane }}-php${{ matrix.php }}
path: ${{ runner.temp }}/behat
retention-days: 14
85 changes: 85 additions & 0 deletions features/COVERAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# log-command Behat coverage

Suite: `log.feature` (command surface, site resolution, errors), `log-site.feature` (single-site logs), `log-global.feature` (`--cli`, `--global`). Shared steps live in the marked section at the end of `bootstrap/FeatureContext.php`.

## Inventory

The package registers one command, `ee log`, with one subcommand, `show` (there is no `tail` subcommand: `show` itself ends in `tail -n <n> -f <files>` via `system()`).

`ee log show [<site-name>] [--n=<line-count>] [--global] [--cli] [--all] [--nginx] [--php] [--wp] [--access] [--error]`

| Item | Behaviour (from `src/Log_Command.php`) | Covered by |
|---|---|---|
| `ee log` (no subcommand) | Prints usage of `show` | log.feature "ee log without a subcommand lists show" |
| `ee help log show` | Synopsis and option docs | log.feature "ee help log show documents every option" |
| `ee log tail` | Not a subcommand, framework error | log.feature "log tail is not a registered subcommand" |
| Unknown / misspelt flag | Framework "unknown --x parameter" (+ suggestion), exit 1 | log.feature "An unknown flag…", "A misspelt flag…" |
| Extra positional | "Too many positional arguments", exit 1 | log.feature "Too many positional arguments…" |
| `<site-name>` omitted, outside a site dir | `auto_site_name()` error, exit 1 | log.feature "Without a site name…" |
| `<site-name>` omitted, inside a site dir | Site detected from cwd (`get_site_name()` matches the logical path; the step passes it as `PWD` because `app/` is a volume symlink) | log.feature "The site name is detected from the current directory" |
| Unknown site | `auto_site_name()` error ("Could not find the site…") | log.feature "An unknown site is reported" |
| Wrong name inside a site dir | "…is not a valid site-name. Did you mean…" | log.feature "A wrong site name inside a site directory…" |
| Disabled site | `get_site_info()` "is not enabled" error | log.feature "A disabled site is refused" |
| Trailing slash in site name | Accepted | log.feature "A trailing slash…" |
| `http://<site>/` form | Should resolve; fails (`auto_site_name()` returns the URL form unchanged) | not covered yet (its scenario comes back with the fix) |
| Default types (nginx, php, wp) on html | nginx `access.log` + `error.log` only | log-site "html site default…" |
| Default types on php | nginx + php logs, no wp warning | log-site "php site default…" |
| Default types on wp | nginx + php logs, "wp log doesn't exist!" warning when no debug.log | log-site "wp site default…" |
| `--nginx` | All `*.log` under `logs/nginx` (recursive) | log-site "--nginx…", "Log files in subdirectories…" |
| `--php` | All `*.log` under `logs/php` | log-site "--php…" |
| `--wp` | `app/htdocs/wp-content/debug.log`; warning + "No logs found!" when missing | log-site "--wp without a debug.log…", "--wp follows…" |
| `--access` | Files named `access.log` in nginx (+ php for non-html) | log-site "--access shows…", "--access only picks files named access.log", "--access and --error on an html site…" |
| `--error` | Files named `error.log` in nginx (+ php for non-html) | log-site "--error shows…", "--access and --error on an html site…" |
| `--all` | All five types | log-site "--all on a wp site…" |
| Combined type flags | Union, de-duplicated (`array_unique`) | log-site "Combined type flags…", "--nginx --php on a php site…" |
| Type the site lacks | "No logs found!", exit 1 | log-site outline "A log type the site does not have…" (html `--php`, html `--wp`, php `--wp`) |
| html/php sites drop the wp type | Intended by `unset()`, broken (the `unset()` is a no-op) | not covered yet (its scenario comes back with the fix) |
| Non-`.log` files and rotated `*.log.1` | Ignored | log-site "Files without a .log extension…" |
| File without extension | PHP warning (`extension` read without `isset()`) | not covered yet (its scenario comes back with the fix) |
| `--n` default 10 | `tail -n 10` | log-site "The default line count is 10" |
| `--n=<k>` / `--n=0` | Initial lines limited / none | log-site "--n limits…", "--n=0…", log-global "--cli honours --n" (exactly one ee.log line) |
| `--n` without value | Framework warning, falls back to 10 | log-site "--n without a value…" |
| `--n=abc` | Rejected up front, exit 1 (fixed in EasyEngine/log-command#8) | log-site "An invalid --n value…" |
| Shell injection via `--n` or a log file name | tail runs without a shell (fixed in EasyEngine/log-command#8) | log-site "--n is not interpreted by the shell", "A log file name is not interpreted by the shell" |
| Following (`tail -f`) | New lines stream until killed | log-site "New lines are streamed while following"; every happy path asserts exit 124 (still running at the timeout) |
| Real nginx access log entry | HTTP request appears in `--access` output | log-site "A real request shows up in the nginx access log" |
| Links in a site log directory | Followed only if the real path is a regular file inside the log directory (the app volume for `debug.log`), otherwise skipped with a warning (fixed in EasyEngine/log-command#8) | log-site outline "A log link that leaves the site's log directory is skipped" (php `logs/php`, html nested `logs/nginx`, wp `debug.log`), "A log link inside the site's log directory is still followed" |
| "watching logfile" lines | `EE::log`, path shown relative to the sites dir; should be hidden by `--quiet`, isn't (core sets up the logger before parsing `--quiet`) | all happy paths; `--quiet` not covered yet (its scenario comes back with the fix) |
| `--cli` | Follows `{EE_ROOT_DIR}/logs/ee.log`; the "watching logfile" line is also written to ee.log (side effect) | log-global "--cli follows EasyEngine's own log" |
| `--cli` with a site name / type flags | Both ignored | log-global "--cli ignores a site name and type flags" |
| `--global` | ee.log + nginx/php/wp logs of every site | log-global "--global follows ee.log and the logs of every site type" |
| `--global`, WP site without debug.log | Warning, keeps following | log-global "--global warns for a WordPress site…" |
| `--global` with `--cli` and a (nonexistent) site name | `--global` wins, site name ignored (documents current behaviour) | log-global "--global takes precedence…" |
| `--global` and disabled sites | Included | log-global "--global includes disabled sites" |
| `--global` undefined `$wp_logs_path` | No PHP warning (fixed in EasyEngine/log-command#8) | log-global "--global raises no PHP warnings…" |
| `--global` with type filters | Filters ignored | not covered yet (its scenario comes back with the fix) |
| Service logs (help promises them for `--global`/`--all`) | Never followed | not covered yet (its scenario comes back with the fix) |
| `--cli`/`--global` "cli log doesn't exist!" and global "No logs found!" | Not covered: unreachable, ee's file logger creates ee.log before the command runs | – |
| Local-DB mariadb logs (`services/mariadb/logs`) | Not covered: never read by the command (part of the missing service logs below), and a local-DB site adds ~1 min | – |

Side effects: the command is read-only apart from appending its "watching logfile" lines to ee.log (asserted). There are no DB rows, containers, proxy config or hooks to verify; idempotency is implicit (every scenario can be rerun; site state is asserted unchanged for the disabled site).

Estimated coverage: about 95% of the branches of `Log_Command` (every branch except the unreachable missing-ee.log paths) once the held-back known-bug scenarios are back; until then the paths of the html/php type drop, `--global` type filters, files without extension and the `http://` site form are not reached.

## Known bugs (scenarios held back until fixed)

The scenarios for the open bugs below assert the fixed behaviour, so they are not in the suite yet and return as regular scenarios with their fixes. The fixed ones are covered by regular scenarios.

- Fixed (High): `--n` and the file list were interpolated unescaped into `system( "tail -n $lines -f $final_string" )`. `logs/php` is writable by www-data in the php container, so a planted file name like `x$(cmd).log` ran as root on the host when the admin followed the logs. Fixed in EasyEngine/log-command#8.
- Fixed (Low): `--n` was not validated and tail's exit status was ignored, so invalid values or tail failures exited 0. Fixed in EasyEngine/log-command#8.
- Open (Low): `unset( $final_types['php'], $final_types['wp'] )` targets string keys of a list, so it is a no-op; html/php sites follow a `wp-content/debug.log` (and an html site would follow `logs/php`).
- Fixed (Low): `--global`: `$wp_logs_path` is only set for WP sites, so a non-WP site before the first WP site raises an undefined-variable warning and later ones reuse a stale path. Fixed in EasyEngine/log-command#8.
- Open (Low): `--global` ignores `--nginx/--php/--wp/--access/--error` (`$final_types` unused), and it also probes nonexistent `logs/access` and `logs/error` directories.
- Open (Low): help says `--global` includes "all services" and `--all` "service logs", but no service logs (nginx-proxy, local mariadb) are followed. The docblock examples also show wrong paths (`~/easyengine/sites/example.com/logs/debug.log`).
- Open (Low): `pathinfo()['extension']` read without `isset()`: a file without extension in a log dir raises a PHP warning.
- Open (Low, site-command helper): `auto_site_name()` accepts `http(s)://<site>/` but returns the args unchanged, so `get_site_info()` fails with "Site http://… does not exist."; site-command's own subcommands use the same pair.
- Fixed (Low): links in a site log directory (writable by the site's containers) were followed by the root `tail` on the host. Fixed in EasyEngine/log-command#8.
- Open (Low, core): the logger is set up before the config and arguments are parsed, so `--quiet` never hides the "watching logfile" lines (found on the CI runner, https://github.com/mrrobot47/log-command/actions/runs/36085159478).

## Tags and runtime

- No `@known-bug` scenarios ship here: the 7 scenario rows written for the open bugs above (2 for the html/php type drop, 1 each for the others) are held back until their fixes land and then return as regular scenarios.
- No `@le`, `@remote` or `@destructive` scenarios: the command needs no public DNS or backups and changes no global state.
- Sites (prefix `bh-log-command-`, deleted by `@AfterFeature`, which only touches that prefix): `-1` html, `-2` php, `-3` wp, `-4` html (disabled), `-99` guaranteed absent. Sites are created lazily by `Given the site … of type … exists`, so each scenario runs on its own and only the first scenario of a feature pays for creation.
- Every `ee log show` run is wrapped in `timeout` (own process group, so `tail` dies too); exit 124 means "still following".
- Estimated runtime: about 12 minutes (site creation ~4 minutes over the three features, WordPress dominating; 52 scenarios at 3–10 s each).
Loading