diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml new file mode 100644 index 0000000..7e67dd5 --- /dev/null +++ b/.github/workflows/behat.yml @@ -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 diff --git a/features/COVERAGE.md b/features/COVERAGE.md new file mode 100644 index 0000000..345107f --- /dev/null +++ b/features/COVERAGE.md @@ -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 -f ` via `system()`). + +`ee log show [] [--n=] [--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…" | +| `` omitted, outside a site dir | `auto_site_name()` error, exit 1 | log.feature "Without a 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:///` 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=` / `--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):///` 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). diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php new file mode 100644 index 0000000..04d947a --- /dev/null +++ b/features/bootstrap/FeatureContext.php @@ -0,0 +1,899 @@ +init_logger(); +$runner->init_config(); +/* End. Loading required files to enable EE::launch() in tests. */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\AfterFeatureScope; +use Behat\Behat\Hook\Scope\AfterScenarioScope; + +use Behat\Gherkin\Node\PyStringNode, + Behat\Gherkin\Node\TableNode; + +define( 'EE_SITE_ROOT', EE_ROOT_DIR . '/sites' ); + +class FeatureContext implements Context +{ + /** + * Whether this run built ee.phar/ee-old.phar, so the cleanup only removes its own files. + * + * @var bool + */ + private static $phar_built = false; + + public $command; + public $commands; + public $webroot_path; + public $ee_path; + + /** + * Only sites starting with this prefix are ever created or deleted by this suite. + */ + const SITE_PREFIX = 'bh-log-command-'; + + /** + * Initializes context. + */ + public function __construct() + { + $this->commands = []; + $this->ee_path = getcwd(); + } + + /** + * @Given ee phar is generated + */ + public function eePharIsPresent() + { + // Checks if phar already exists, replaces it + if(file_exists('ee-old.phar')) { + // Below exec call is required as currenly `ee cli update` is ran with root + // which updates ee.phar with root privileges. + exec("sudo rm ee.phar"); + copy('ee-old.phar','ee.phar'); + self::$phar_built = true; + return 0; + } + exec("php -dphar.readonly=0 utils/make-phar.php ee.phar", $output, $return_status); + if (0 !== $return_status) { + throw new Exception("Unable to generate phar" . $return_status); + } + + // Cache generaed phar as it is expensive to generate one + copy('ee.phar','ee-old.phar'); + self::$phar_built = true; + } + + /** + * @Given :command is installed + */ + public function isInstalled($command) + { + exec("type " . $command, $output, $return_status); + if (0 !== $return_status) { + throw new Exception($command . " is not installed! Exit code is:" . $return_status); + } + } + + /** + * @When /^I run ['"](.*)['"]$/ + */ + public function iRun($command) + { + $this->commands[] = EE::launch($command, false, true); + } + + /** + * @Then return value should be 0 + */ + public function returnValueShouldBe0() + { + if ( 0 !== $this->commands[0]->return_code ) { + throw new Exception("Actual return code is not zero: \n" . $this->command); + } + } + + /** + * @Then return value of command :index should be 0 + */ + public function returnValueOfCommandShouldBe0($index) + { + if ( 0 !== $this->commands[ $index - 1 ]->return_code ) { + throw new Exception("Actual return code is not zero: \n" . $this->command); + } + } + + /** + * @Then After delay of :time seconds + */ + public function afterDelayOfSeconds( $time ) + { + sleep( $time ); + } + + /** + * @Then /^(STDOUT|STDERR) should return exactly$/ + */ + public function stdoutShouldReturnExactly($output_stream, PyStringNode $expected_output) + { + $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; + + $command_output = str_replace(["\033[1;31m","\033[0m"],'',$command_output); + + if ( $expected_output->getRaw() !== trim($command_output)) { + throw new Exception("Actual output is:\n" . $command_output); + } + } + + /** + * @Then /^(STDOUT|STDERR) of command (\d+) should return exactly$/ + */ + public function stdoutOfCommandShouldReturnExactly($output_stream, $index, PyStringNode $expected_output) + { + $command_output = $output_stream === "STDOUT" ? $this->commands[ $index - 1 ]->stdout : $this->commands[ $index - 1 ]->stderr; + + $command_output = str_replace(["\033[1;31m","\033[0m"],'',$command_output); + + if ( $expected_output->getRaw() !== trim($command_output)) { + throw new Exception("Actual output is:\n" . $command_output); + } + } + + /** + * @Then /^(STDOUT|STDERR) should return something like$/ + */ + public function stdoutShouldReturnSomethingLike($output_stream, PyStringNode $expected_output) + { + $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; + + if (strpos($command_output, $expected_output->getRaw()) === false) { + throw new Exception("Actual output is:\n" . $command_output); + } + } + + + /** + * @Then /^(STDOUT|STDERR) of command (\d+) should return something like$/ + */ + public function stdoutOfCommandShouldReturnSomethingLike($output_stream, $index, PyStringNode $expected_output) + { + $command_output = $output_stream === "STDOUT" ? $this->commands[ $index - 1 ]->stdout : $this->commands[ $index - 1 ]->stderr; + + if (strpos($command_output, $expected_output->getRaw()) === false) { + throw new Exception("Actual output is:\n" . $command_output); + } + } + + /** + * @Then The site :site should be :type multisite + */ + public function theSiteShouldBeMultisite( $site, $type ) + { + $result = EE::launch( "cd " . EE_SITE_ROOT . "/$site && docker-compose exec --user='www-data' php sh -c 'wp config get SUBDOMAIN_INSTALL'", false, true ); + + if( $result->stderr ) { + throw new Exception("Found error while executing command: $result->stderr"); + } + + if($type === 'subdomain' && trim($result->stdout) !== '1') { + throw new Exception("Expecting SUBDOMAIN_INSTALL to be 1. Got: $result->stdout"); + } + else if($type === 'subdir' && trim($result->stdout) !== '') { + throw new Exception("Expecting SUBDOMAIN_INSTALL to be empty. Got: $result->stdout"); + } + else if($type !== 'subdomain' && $type !== 'subdir') { + throw new Exception("Found unknown site type: $type"); + } + } + + /** + * @Then The :site db entry should be removed + */ + public function theDbEntryShouldBeRemoved($site) + { + $out = shell_exec("sudo bin/ee site list"); + if (strpos($out, $site) !== false) { + throw new Exception("$site db entry not been removed!"); + } + + } + + /** + * @Then The :site webroot should be removed + */ + public function theWebrootShouldBeRemoved($site) + { + if ( file_exists( EE_SITE_ROOT . '/' . $site ) ) { + throw new Exception("Webroot has not been removed!"); + } + } + + /** + * @Then Following containers of site :site should be removed: + */ + public function followingContainersOfSiteShouldBeRemoved($site, TableNode $table) + { + $containers = $table->getHash(); + $site_name = implode(explode('.', $site)); + + foreach ($containers as $container) { + + $sevice = $container['container']; + $container_name = $site_name . '_' . $sevice . '_1'; + + exec("docker inspect -f '{{.State.Running}}' $container_name > /dev/null 2>&1", $exec_out, $return); + if (!$return) { + throw new Exception("$container_name has not been removed!"); + } + } + } + + /** + * @Then The site :site should have webroot + */ + public function theSiteShouldHaveWebroot($site) + { + if ( ! file_exists( EE_SITE_ROOT . '/' . $site ) ) { + throw new Exception("Webroot has not been created!"); + } + } + + /** + * @Then The site :site should have WordPress + */ + public function theSiteShouldHaveWordpress($site) + { + if ( ! file_exists( EE_SITE_ROOT . '/' . $site . '/app/wp-config.php' ) ) { + throw new Exception("WordPress data not found!"); + } + } + + /** + * @Then Request on :site should contain following headers: + */ + public function requestOnShouldContainFollowingHeaders($site, TableNode $table) + { + $url = 'http://' . $site; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_NOBODY, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_VERBOSE, true); + $headers = curl_exec($ch); + + curl_close($ch); + + $rows = $table->getHash(); + + foreach ($rows as $row) { + if (strpos($headers, $row['header']) === false) { + throw new Exception("Unable to find " . $row['header'] . "\nActual output is : " . $headers); + } + } + } + + /** + * @Then Request on :host with header :header should contain following headers: + */ + public function requestOnWithHeaderShouldContainFollowingHeaders($host, $header, TableNode $table) + { + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $host); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ $header ]); + curl_setopt($ch, CURLOPT_NOBODY, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_VERBOSE, true); + $headers = curl_exec($ch); + + curl_close($ch); + + $rows = $table->getHash(); + + foreach ($rows as $row) { + if (strpos($headers, $row['header']) === false) { + throw new Exception("Unable to find " . $row['header'] . "\nActual output is : " . $headers); + } + } + } + + /** + * @Then Request on :host with resolve option :resolve should contain following headers: + */ + public function requestOnWithResolveOptionShouldContainFollowingHeaders($host, $resolve, TableNode $table) + { + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $host); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_NOBODY, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_RESOLVE, [ $resolve ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_VERBOSE, true); + $headers = curl_exec($ch); + curl_close($ch); + + $rows = $table->getHash(); + + foreach ($rows as $row) { + if (strpos($headers, $row['header']) === false) { + throw new Exception("Unable to find " . $row['header'] . "\nActual output is : " . $headers); + } + } + } + + /** + * @AfterScenario + */ + public function cleanupScenario(AfterScenarioScope $scope) + { + $this->commands = []; + chdir($this->ee_path); + } + + /** + * @Then There should be :expected_running_containers containers with labels + */ + public function thereShouldBeContainersWithLabel($expected_running_containers, PyStringNode $pyStringNode) + { + $labels = $pyStringNode->getStrings(); + $label_string = implode(' -f label=',$labels); + + $result = EE::launch( "docker ps -qf label=$label_string | wc -l", false, true ); + $running_containers = (int) trim($result->stdout); + + if ( (int) $expected_running_containers !== $running_containers ) { + throw new Exception("Expected $expected_running_containers running containers. Found: $running_containers"); + } + } + + /** + * Deletes only this suite's sites (never every site) and the cached phars. + * + * @AfterFeature + */ + public static function cleanup( AfterFeatureScope $scope ) + { + foreach ( self::sitesWithPrefix( self::SITE_PREFIX ) as $site ) { + EE::launch( 'bin/ee site delete ' . escapeshellarg( $site ) . ' --yes', false, true ); + } + + if ( self::$phar_built ) { + foreach ( [ 'ee.phar', 'ee-old.phar' ] as $phar ) { + if ( file_exists( $phar ) ) { + unlink( $phar ); + } + } + self::$phar_built = false; + } + } + + /* + * --------------------------------------------------------------------- + * Shared generic steps (added for log-command; generic enough to share). + * + * Paths and expected texts accept the placeholders {EE_ROOT_DIR} + * (/opt/easyengine), {SITES_DIR} ({EE_ROOT_DIR}/sites) and {EE_ROOT} + * (the core checkout running the suite). + * --------------------------------------------------------------------- + */ + + /** + * Files and directories created by steps, removed after each scenario (newest first). + * + * @var array + */ + private $created_paths = []; + + /** + * Replaces the path placeholders in a step argument. + */ + private function expand( $text ) + { + return strtr( + $text, + [ + '{EE_ROOT_DIR}' => EE_ROOT_DIR, + '{SITES_DIR}' => EE_SITE_ROOT, + '{EE_ROOT}' => $this->ee_path, + ] + ); + } + + /** + * Runs a query against EasyEngine's sqlite DB (read-only use). + */ + private static function dbQuery( $sql, array $params = [] ) + { + $db_file = EE_ROOT_DIR . '/db/ee.sqlite'; + if ( ! file_exists( $db_file ) ) { + return []; + } + $db = new PDO( 'sqlite:' . $db_file ); + $db->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + $stmt = $db->prepare( $sql ); + $stmt->execute( $params ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + private static function sitesWithPrefix( $prefix ) + { + $sites = array_column( self::dbQuery( 'SELECT site_url FROM sites' ), 'site_url' ); + + return array_values( + array_filter( + $sites, + function ( $site ) use ( $prefix ) { + return 0 === strpos( $site, $prefix ); + } + ) + ); + } + + private static function siteRow( $site ) + { + $rows = self::dbQuery( 'SELECT site_url, site_type, site_enabled, site_fs_path FROM sites WHERE site_url = ?', [ $site ] ); + + return empty( $rows ) ? null : $rows[0]; + } + + private static function assertOwnSite( $site ) + { + if ( 0 !== strpos( $site, self::SITE_PREFIX ) ) { + throw new Exception( "Refusing to manage '$site': this suite only touches sites starting with '" . self::SITE_PREFIX . "'." ); + } + } + + /** + * Write steps may only touch files inside this suite's own site directories. + */ + private static function assertOwnPath( $path ) + { + $own_root = EE_SITE_ROOT . '/' . self::SITE_PREFIX; + if ( 0 !== strpos( $path, $own_root ) || false !== strpos( $path, '..' ) ) { + throw new Exception( "Refusing to modify '$path': only paths under '$own_root*' are allowed." ); + } + } + + private static function stripAnsi( $text ) + { + return preg_replace( '/\e\[[0-9;]*m/', '', $text ); + } + + private static function describe( $result ) + { + return sprintf( + "Command: %s\nReturn code: %s\n--- STDOUT ---\n%s\n--- STDERR ---\n%s", + $result->command, + $result->return_code, + self::stripAnsi( $result->stdout ), + self::stripAnsi( $result->stderr ) + ); + } + + private function lastCommand() + { + if ( empty( $this->commands ) ) { + throw new Exception( 'No command has been run in this scenario.' ); + } + + return end( $this->commands ); + } + + private function streamOf( $stream ) + { + $result = $this->lastCommand(); + + return self::stripAnsi( 'STDOUT' === $stream ? $result->stdout : $result->stderr ); + } + + /** + * Mirrors mkdir -p and remembers what it created. + */ + private function makeDir( $dir ) + { + $missing = []; + while ( ! is_dir( $dir ) ) { + $missing[] = $dir; + $dir = dirname( $dir ); + } + foreach ( array_reverse( $missing ) as $new_dir ) { + if ( ! mkdir( $new_dir ) ) { + throw new Exception( "Could not create directory $new_dir" ); + } + $this->created_paths[] = $new_dir; + } + } + + /** + * Runs a command in its own process group under timeout(1), so long-running followers + * (e.g. `tail -f`) are killed together with ee. Exit code 124 means the timeout expired. + */ + private function launchWithTimeout( $command, $seconds, $cwd = null, $before = '' ) + { + // proc_open's cwd resolves symlinks (a site's app/ links to a docker volume), so also pass the + // logical path as PWD, like an interactive `cd` would; get_site_name() matches on it. + $timed = sprintf( + 'timeout -k 5 %d env BEHAT_RUN=1%s sh -c %s', + (int) $seconds, + null === $cwd ? '' : ' PWD=' . escapeshellarg( $cwd ), + escapeshellarg( $this->expand( $command ) ) + ); + if ( '' !== $before ) { + $timed = "$before $timed; rc=\$?; wait; exit \$rc"; + } + + return EE::launch( $timed, false, true, [], null, $cwd ); + } + + /** + * @Given the site :site of type :type exists + */ + public function theSiteOfTypeExists( $site, $type ) + { + self::assertOwnSite( $site ); + $row = self::siteRow( $site ); + + if ( $row && $row['site_type'] !== $type ) { + throw new Exception( "Site $site already exists with type {$row['site_type']}, expected $type." ); + } + + if ( ! $row ) { + $result = EE::launch( 'bin/ee site create ' . escapeshellarg( $site ) . ' --type=' . escapeshellarg( $type ), false, true ); + $row = self::siteRow( $site ); + if ( 0 !== $result->return_code || ! $row ) { + throw new Exception( "Could not create site $site.\n" . self::describe( $result ) ); + } + } + + if ( ! $row['site_enabled'] ) { + $result = EE::launch( 'bin/ee site enable ' . escapeshellarg( $site ), false, true ); + $row = self::siteRow( $site ); + if ( ! $row['site_enabled'] ) { + throw new Exception( "Could not enable site $site.\n" . self::describe( $result ) ); + } + } + } + + /** + * @Given the site :site is disabled + */ + public function theSiteIsDisabled( $site ) + { + self::assertOwnSite( $site ); + $row = self::siteRow( $site ); + if ( ! $row ) { + throw new Exception( "Site $site does not exist." ); + } + + if ( $row['site_enabled'] ) { + $result = EE::launch( 'bin/ee site disable ' . escapeshellarg( $site ), false, true ); + $row = self::siteRow( $site ); + if ( $row['site_enabled'] ) { + throw new Exception( "Could not disable site $site.\n" . self::describe( $result ) ); + } + } + } + + /** + * @Then the site :site should be :state + */ + public function theSiteShouldBe( $site, $state ) + { + $row = self::siteRow( $site ); + $actual = ! $row ? 'missing' : ( $row['site_enabled'] ? 'enabled' : 'disabled' ); + if ( $actual !== $state ) { + throw new Exception( "Expected site $site to be $state, it is $actual." ); + } + } + + /** + * @Given the site :site does not exist + */ + public function theSiteDoesNotExist( $site ) + { + self::assertOwnSite( $site ); + if ( self::siteRow( $site ) ) { + $result = EE::launch( 'bin/ee site delete ' . escapeshellarg( $site ) . ' --yes', false, true ); + if ( self::siteRow( $site ) ) { + throw new Exception( "Could not delete site $site.\n" . self::describe( $result ) ); + } + } + } + + /** + * @Given the file :path contains: + */ + public function theFileContains( $path, PyStringNode $content ) + { + $path = $this->expand( $path ); + self::assertOwnPath( $path ); + $this->makeDir( dirname( $path ) ); + if ( ! file_exists( $path ) ) { + $this->created_paths[] = $path; + } + if ( false === file_put_contents( $path, $this->expand( $content->getRaw() ) . "\n" ) ) { + throw new Exception( "Could not write $path" ); + } + } + + /** + * Plants a link the way the site's own containers could, pointing at a marker file outside the site. + * + * @Given the file :path is a link to a file outside the site containing :text + */ + public function theFileIsALinkToAFileOutsideTheSite( $path, $text ) + { + $path = $this->expand( $path ); + self::assertOwnPath( $path ); + $this->makeDir( dirname( $path ) ); + $target = tempnam( sys_get_temp_dir(), 'bh-log-outside-' ); + $this->created_paths[] = $target; + if ( false === file_put_contents( $target, $text . "\n" ) || ! symlink( $target, $path ) ) { + throw new Exception( "Could not link $path to $target" ); + } + $this->created_paths[] = $path; + } + + /** + * @Given the file :path is a link to :target + */ + public function theFileIsALinkTo( $path, $target ) + { + $path = $this->expand( $path ); + self::assertOwnPath( $path ); + $this->makeDir( dirname( $path ) ); + if ( ! symlink( $this->expand( $target ), $path ) ) { + throw new Exception( "Could not link $path to $target" ); + } + $this->created_paths[] = $path; + } + + /** + * @Given the file :path does not exist + */ + public function theFileDoesNotExist( $path ) + { + $path = $this->expand( $path ); + self::assertOwnPath( $path ); + if ( file_exists( $path ) && ! unlink( $path ) ) { + throw new Exception( "Could not delete $path" ); + } + } + + /** + * @Then the file :path should exist + */ + public function theFileShouldExist( $path ) + { + $path = $this->expand( $path ); + if ( ! file_exists( $path ) ) { + throw new Exception( "Expected file $path to exist." ); + } + } + + /** + * @Then the file :path should contain :text + */ + public function theFileShouldContain( $path, $text ) + { + $path = $this->expand( $path ); + $text = $this->expand( $text ); + if ( ! file_exists( $path ) ) { + throw new Exception( "Expected file $path to exist." ); + } + $content = file_get_contents( $path ); + if ( false === strpos( $content, $text ) ) { + throw new Exception( "Expected $path to contain '$text'. Last 2 KB:\n" . substr( $content, -2048 ) ); + } + } + + /** + * @When I run :command with a timeout of :seconds seconds + */ + public function iRunWithATimeoutOfSeconds( $command, $seconds ) + { + $this->commands[] = $this->launchWithTimeout( $command, $seconds ); + } + + /** + * Runs from another working directory; a leading `bin/ee` still means this checkout's bin/ee. + * + * @When I run :command from :dir with a timeout of :seconds seconds + */ + public function iRunFromWithATimeoutOfSeconds( $command, $dir, $seconds ) + { + $dir = $this->expand( $dir ); + if ( ! is_dir( $dir ) ) { + throw new Exception( "Directory $dir does not exist." ); + } + $ee = escapeshellarg( $this->ee_path . '/bin/ee' ); + $command = preg_replace_callback( + '#(^|\s)bin/ee(?=\s|$)#', + function ( $m ) use ( $ee ) { + return $m[1] . $ee; + }, + $command + ); + + $this->commands[] = $this->launchWithTimeout( $command, $seconds, $dir ); + } + + /** + * Appends :text to :path once a `tail -f` on that file is running, to prove live following. + * + * @When I run :command with a timeout of :seconds seconds while :text is appended to the file :path once tail follows it + */ + public function iRunWhileTextIsAppendedOnceTailFollows( $command, $seconds, $text, $path ) + { + $path = $this->expand( $path ); + self::assertOwnPath( $path ); + if ( ! file_exists( $path ) ) { + throw new Exception( "File $path must exist before it can be followed." ); + } + + // Anchored so only the tail process itself matches, never a wrapper shell's command line. + $pattern = '^tail .*-f .*' . basename( $path ); + $writer = sprintf( + '( i=0; while [ $i -lt 150 ] && ! pgrep -f -- %1$s >/dev/null; do sleep 0.1; i=$((i+1)); done; sleep 1; printf "%%s\n" %2$s >> %3$s ) >/dev/null 2>&1 &', + escapeshellarg( $pattern ), + escapeshellarg( $this->expand( $text ) ), + escapeshellarg( $path ) + ); + + $this->commands[] = $this->launchWithTimeout( $command, $seconds, null, $writer ); + } + + /** + * @Then the command should still be running when the timeout expires + */ + public function theCommandShouldStillBeRunningWhenTheTimeoutExpires() + { + $result = $this->lastCommand(); + if ( 124 !== $result->return_code ) { + throw new Exception( "Expected the command to still be running at the timeout (exit 124).\n" . self::describe( $result ) ); + } + } + + /** + * @Then the command should exit with code :code + */ + public function theCommandShouldExitWithCode( $code ) + { + $result = $this->lastCommand(); + if ( (int) $code !== $result->return_code ) { + throw new Exception( "Expected exit code $code.\n" . self::describe( $result ) ); + } + } + + /** + * @Then the command should exit with a non-zero code + */ + public function theCommandShouldExitWithANonZeroCode() + { + $result = $this->lastCommand(); + if ( 0 === $result->return_code || 124 === $result->return_code ) { + throw new Exception( "Expected the command to fail on its own (non-zero exit, not a timeout).\n" . self::describe( $result ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should (not )?contain "(.*)"$/ + */ + public function streamShouldContain( $stream, $negate, $text ) + { + $text = $this->expand( $text ); + $found = false !== strpos( $this->streamOf( $stream ), $text ); + $expected = '' === $negate; + if ( $found !== $expected ) { + throw new Exception( sprintf( "Expected %s %sto contain '%s'.\n%s", $stream, $negate, $text, self::describe( $this->lastCommand() ) ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should contain "(.*)" exactly (\d+) times?$/ + */ + public function streamShouldContainTimes( $stream, $text, $times ) + { + $text = $this->expand( $text ); + $count = substr_count( $this->streamOf( $stream ), $text ); + if ( (int) $times !== $count ) { + throw new Exception( sprintf( "Expected %s to contain '%s' %d time(s), found %d.\n%s", $stream, $text, $times, $count, self::describe( $this->lastCommand() ) ) ); + } + } + + /** + * @Then a GET request to :url with Host :host should return :code + */ + public function aGetRequestWithHostShouldReturn( $url, $host, $code ) + { + $ch = curl_init(); + curl_setopt( $ch, CURLOPT_URL, $this->expand( $url ) ); + curl_setopt( $ch, CURLOPT_HTTPHEADER, [ 'Host: ' . $host ] ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, false ); + curl_setopt( $ch, CURLOPT_TIMEOUT, 30 ); + $body = curl_exec( $ch ); + $actual = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $error = curl_error( $ch ); + curl_close( $ch ); + + if ( (int) $code !== $actual ) { + throw new Exception( "Expected HTTP $code from $url (Host: $host), got $actual. $error\n" . substr( (string) $body, 0, 1024 ) ); + } + } + + /** + * @AfterScenario + */ + public function removeCreatedPaths( AfterScenarioScope $scope ) + { + foreach ( array_reverse( $this->created_paths ) as $path ) { + if ( is_dir( $path ) && ! is_link( $path ) ) { + @rmdir( $path ); + } elseif ( file_exists( $path ) || is_link( $path ) ) { + @unlink( $path ); + } + } + $this->created_paths = []; + } +} diff --git a/features/bootstrap/support.php b/features/bootstrap/support.php new file mode 100644 index 0000000..048f72e --- /dev/null +++ b/features/bootstrap/support.php @@ -0,0 +1,223 @@ + $value ) { + if ( ! property_exists( $actual, $name ) || ! compareContents( $value, $actual->$name ) ) { + return false; + } + } + } else if ( is_array( $expected ) && array_values( $expected ) === $expected ) { + // Lists match by membership, so [1,3,5] is contained in [1,2,3,4,5]. + foreach ( $expected as $value ) { + $found = false; + foreach ( $actual as $candidate ) { + if ( compareContents( $value, $candidate ) ) { + $found = true; + break; + } + } + if ( ! $found ) { + return false; + } + } + } else if ( is_array( $expected ) ) { + foreach ( $expected as $key => $value ) { + if ( ! array_key_exists( $key, $actual ) || ! compareContents( $value, $actual[ $key ] ) ) { + return false; + } + } + } else { + return $expected === $actual; + } + + return true; +} + +/** + * Compare two strings containing JSON to ensure that @a $actualJson contains at + * least what the JSON string @a $expectedJson contains. + * + * @return whether or not @a $actualJson contains @a $expectedJson + * @retval true @a $actualJson contains @a $expectedJson + * @retval false @a $actualJson does not contain @a $expectedJson + * + * @param [in] $actualJson the JSON string to be tested + * @param [in] $expectedJson the expected JSON string + * + * Examples: + * expected: {'a':1,'array':[1,3,5]} + * + * 1 ) + * actual: {'a':1,'b':2,'c':3,'array':[1,2,3,4,5]} + * return: true + * + * 2 ) + * actual: {'b':2,'c':3,'array':[1,2,3,4,5]} + * return: false + * element 'a' is missing from the root object + * + * 3 ) + * actual: {'a':0,'b':2,'c':3,'array':[1,2,3,4,5]} + * return: false + * the value of element 'a' is not 1 + * + * 4 ) + * actual: {'a':1,'b':2,'c':3,'array':[1,2,4,5]} + * return: false + * the contents of 'array' does not include 3 + */ +function checkThatJsonStringContainsJsonString( $actualJson, $expectedJson ) { + $actualValue = json_decode( $actualJson ); + $expectedValue = json_decode( $expectedJson ); + + if ( ! $actualValue ) { + return false; + } + + return compareContents( $expectedValue, $actualValue ); +} + +/** + * Compare two strings to confirm $actualCSV contains $expectedCSV + * Both strings are expected to have headers for their CSVs. + * $actualCSV must match all data rows in $expectedCSV + * + * @param string A CSV string + * @param array A nested array of values + * + * @return bool Whether $actualCSV contains $expectedCSV + */ +function checkThatCsvStringContainsValues( $actualCSV, $expectedCSV ) { + $actualCSV = array_map( 'str_getcsv', explode( PHP_EOL, $actualCSV ) ); + + if ( empty( $actualCSV ) ) { + return false; + } + + // Each sample must have headers + $actualHeaders = array_values( array_shift( $actualCSV ) ); + $expectedHeaders = array_values( array_shift( $expectedCSV ) ); + + // Each expectedCSV must exist somewhere in actualCSV in the proper column + $expectedResult = 0; + foreach ( $expectedCSV as $expected_row ) { + $expected_row = array_combine( $expectedHeaders, $expected_row ); + foreach ( $actualCSV as $actual_row ) { + + if ( count( $actualHeaders ) != count( $actual_row ) ) { + continue; + } + + $actual_row = array_intersect_key( array_combine( $actualHeaders, $actual_row ), $expected_row ); + if ( $actual_row == $expected_row ) { + $expectedResult ++; + // One match per expected row, so duplicates can't stand in for a missing row. + break; + } + } + } + + return $expectedResult >= count( $expectedCSV ); +} + +/** + * Compare two strings containing YAML to ensure that @a $actualYaml contains at + * least what the YAML string @a $expectedYaml contains. + * + * @return whether or not @a $actualYaml contains @a $expectedJson + * @retval true @a $actualYaml contains @a $expectedJson + * @retval false @a $actualYaml does not contain @a $expectedJson + * + * @param [in] $actualYaml the YAML string to be tested + * @param [in] $expectedYaml the expected YAML string + */ +function checkThatYamlStringContainsYamlString( $actualYaml, $expectedYaml ) { + $actualValue = Mustangostang\Spyc::YAMLLoad( $actualYaml ); + $expectedValue = Mustangostang\Spyc::YAMLLoad( $expectedYaml ); + + if ( ! $actualValue ) { + return false; + } + + return compareContents( $expectedValue, $actualValue ); +} + diff --git a/features/log-global.feature b/features/log-global.feature new file mode 100644 index 0000000..6a2c145 --- /dev/null +++ b/features/log-global.feature @@ -0,0 +1,85 @@ +Feature: ee log show --cli and --global + + # --cli follows {EE_ROOT_DIR}/logs/ee.log; --global follows ee.log plus every site's logs. + # These scenarios only read ee.log; the "watching logfile" lines ee itself writes there are used as markers. + # The html site is created first on purpose: the undefined `$wp_logs_path` bug only shows when a non-WordPress site precedes the first WordPress site. + + Scenario: --cli follows EasyEngine's own log + Given the file "{EE_ROOT_DIR}/logs/ee.log" should exist + When I run "bin/ee log show --cli" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile {EE_ROOT_DIR}/logs/ee.log" + And STDOUT should contain "ee.INFO: watching logfile {EE_ROOT_DIR}/logs/ee.log" + And the file "{EE_ROOT_DIR}/logs/ee.log" should contain "ee.INFO: watching logfile {EE_ROOT_DIR}/logs/ee.log" + And the command should still be running when the timeout expires + + # The last ee.log line is whatever ee logged last (the tail command's debug line since log-command#8), not the INFO line. + Scenario: --cli honours --n + When I run "bin/ee log show --cli --n=1" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile {EE_ROOT_DIR}/logs/ee.log" + And STDOUT should contain "] ee." exactly 1 time + And the command should still be running when the timeout expires + + Scenario: --cli ignores a site name and type flags + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test --cli --nginx" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile {EE_ROOT_DIR}/logs/ee.log" + And STDOUT should not contain "watching logfile bh-log-command-1.test" + And the command should still be running when the timeout expires + + Scenario: --global follows ee.log and the logs of every site type + Given the site "bh-log-command-1.test" of type "html" exists + And the site "bh-log-command-2.test" of type "php" exists + And the site "bh-log-command-3.test" of type "wp" exists + And the file "{SITES_DIR}/bh-log-command-3.test/app/htdocs/wp-content/debug.log" contains: + """ + bh-global-wp-marker + """ + When I run "bin/ee log show --global" with a timeout of 10 seconds + Then STDOUT should contain "watching logfile {EE_ROOT_DIR}/logs/ee.log" + And STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/php/access.log" + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/php/error.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/error.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/app/htdocs/wp-content/debug.log" + And STDOUT should contain "==> {EE_ROOT_DIR}/logs/ee.log <==" + And STDOUT should contain "bh-global-wp-marker" + And STDOUT should not contain "bh-log-command-1.test/logs/php/" + And the command should still be running when the timeout expires + + Scenario: --global warns for a WordPress site without debug.log but keeps following + Given the site "bh-log-command-3.test" of type "wp" exists + And the file "{SITES_DIR}/bh-log-command-3.test/app/htdocs/wp-content/debug.log" does not exist + When I run "bin/ee log show --global" with a timeout of 10 seconds + Then STDERR should contain "Warning: wp log doesn't exist!" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/access.log" + And the command should still be running when the timeout expires + + Scenario: --global takes precedence over --cli and a site name + Given the site "bh-log-command-1.test" of type "html" exists + And the site "bh-log-command-99.test" does not exist + When I run "bin/ee log show bh-log-command-99.test --global --cli" with a timeout of 10 seconds + Then STDERR should not contain "Could not find the site" + And STDOUT should contain "watching logfile {EE_ROOT_DIR}/logs/ee.log" + And STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" + And the command should still be running when the timeout expires + + Scenario: --global includes disabled sites + Given the site "bh-log-command-4.test" of type "html" exists + And the site "bh-log-command-4.test" is disabled + When I run "bin/ee log show --global" with a timeout of 10 seconds + Then STDOUT should contain "watching logfile bh-log-command-4.test/logs/nginx/access.log" + And the command should still be running when the timeout expires + And the site "bh-log-command-4.test" should be disabled + + # Regression check (fixed in EasyEngine/log-command#8): in the --global loop $wp_logs_path is only assigned for WordPress sites, so a non-WordPress site + # before the first WordPress site hits an undefined variable (later ones reuse a stale path). Only exposed while + # the html site's DB row precedes the wp site's (true when the feature runs in order). + Scenario: --global raises no PHP warnings for non-WordPress sites + Given the site "bh-log-command-1.test" of type "html" exists + And the site "bh-log-command-3.test" of type "wp" exists + When I run "bin/ee log show --global" with a timeout of 10 seconds + Then STDERR should not contain "wp_logs_path" + And the command should still be running when the timeout expires diff --git a/features/log-site.feature b/features/log-site.feature new file mode 100644 index 0000000..791ea69 --- /dev/null +++ b/features/log-site.feature @@ -0,0 +1,345 @@ +Feature: ee log show for a single site (type filters, line count, following) + + # Sites: bh-log-command-1.test (html), bh-log-command-2.test (php), bh-log-command-3.test (wp). + # `ee log show` ends in `tail -f`, so runs are bounded by a timeout and exit 124 means "still following". + # Extra *.log files written by scenarios live inside the site's own log volume and are removed after each scenario. + + Scenario: html site default shows only the nginx logs + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile" exactly 2 times + And STDOUT should contain "==> {SITES_DIR}/bh-log-command-1.test/logs/nginx/access.log <==" + And STDOUT should contain "==> {SITES_DIR}/bh-log-command-1.test/logs/nginx/error.log <==" + And STDERR should not contain "Warning" + And the command should still be running when the timeout expires + + Scenario: php site default shows nginx and php logs without a wp warning + Given the site "bh-log-command-2.test" of type "php" exists + When I run "bin/ee log show bh-log-command-2.test" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-2.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/php/access.log" + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/php/error.log" + And STDOUT should contain "watching logfile" exactly 4 times + And STDERR should not contain "wp log doesn't exist" + And the command should still be running when the timeout expires + + Scenario: wp site default shows nginx and php logs and warns about the missing debug.log + Given the site "bh-log-command-3.test" of type "wp" exists + And the file "{SITES_DIR}/bh-log-command-3.test/app/htdocs/wp-content/debug.log" does not exist + When I run "bin/ee log show bh-log-command-3.test" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/access.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/error.log" + And STDOUT should contain "watching logfile" exactly 4 times + And STDERR should contain "Warning: wp log doesn't exist!" + And the command should still be running when the timeout expires + + Scenario: --nginx shows only the nginx logs + Given the site "bh-log-command-3.test" of type "wp" exists + When I run "bin/ee log show bh-log-command-3.test --nginx" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile" exactly 2 times + And STDOUT should not contain "/logs/php/" + And STDERR should not contain "wp log doesn't exist" + And the command should still be running when the timeout expires + + Scenario: --php shows only the php logs + Given the site "bh-log-command-3.test" of type "wp" exists + When I run "bin/ee log show bh-log-command-3.test --php" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/access.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/error.log" + And STDOUT should contain "watching logfile" exactly 2 times + And STDOUT should not contain "/logs/nginx/" + And the command should still be running when the timeout expires + + Scenario: --wp without a debug.log warns and fails + Given the site "bh-log-command-3.test" of type "wp" exists + And the file "{SITES_DIR}/bh-log-command-3.test/app/htdocs/wp-content/debug.log" does not exist + When I run "bin/ee log show bh-log-command-3.test --wp" with a timeout of 20 seconds + Then STDERR should contain "Warning: wp log doesn't exist!" + And STDERR should contain "Error: No logs found!" + And STDOUT should not contain "watching logfile" + And the command should exit with code 1 + + Scenario: --wp follows the WordPress debug.log + Given the site "bh-log-command-3.test" of type "wp" exists + And the file "{SITES_DIR}/bh-log-command-3.test/app/htdocs/wp-content/debug.log" contains: + """ + bh-wp-debug-marker + """ + When I run "bin/ee log show bh-log-command-3.test --wp" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/app/htdocs/wp-content/debug.log" + And STDOUT should contain "watching logfile" exactly 1 time + And STDOUT should contain "bh-wp-debug-marker" + And STDERR should not contain "Warning" + And the command should still be running when the timeout expires + + Scenario: --all on a wp site shows nginx, php and the debug.log + Given the site "bh-log-command-3.test" of type "wp" exists + And the file "{SITES_DIR}/bh-log-command-3.test/app/htdocs/wp-content/debug.log" contains: + """ + bh-wp-all-marker + """ + When I run "bin/ee log show bh-log-command-3.test --all" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/access.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/error.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/app/htdocs/wp-content/debug.log" + And STDOUT should contain "watching logfile" exactly 5 times + And STDOUT should contain "bh-wp-all-marker" + And the command should still be running when the timeout expires + + Scenario: --access shows the nginx and php access logs only + Given the site "bh-log-command-3.test" of type "wp" exists + When I run "bin/ee log show bh-log-command-3.test --access" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/access.log" + And STDOUT should contain "watching logfile" exactly 2 times + And STDOUT should not contain "error.log" + And the command should still be running when the timeout expires + + Scenario: --error shows the nginx and php error logs only + Given the site "bh-log-command-3.test" of type "wp" exists + When I run "bin/ee log show bh-log-command-3.test --error" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/error.log" + And STDOUT should contain "watching logfile" exactly 2 times + And STDOUT should not contain "access.log" + And the command should still be running when the timeout expires + + Scenario: --access and --error on an html site use the nginx logs only + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test --access --error" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile" exactly 2 times + And the command should still be running when the timeout expires + + Scenario: --access only picks files named access.log + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-other.log" contains: + """ + bh-other-marker + """ + When I run "bin/ee log show bh-log-command-1.test --access" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile" exactly 1 time + And STDOUT should not contain "bh-other" + And the command should still be running when the timeout expires + + Scenario: Combined type flags are merged without duplicates + Given the site "bh-log-command-3.test" of type "wp" exists + When I run "bin/ee log show bh-log-command-3.test --nginx --access" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/access.log" exactly 1 time + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/error.log" exactly 1 time + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/access.log" exactly 1 time + And STDOUT should contain "watching logfile" exactly 3 times + And the command should still be running when the timeout expires + + Scenario: --nginx --php on a php site shows both log directories + Given the site "bh-log-command-2.test" of type "php" exists + When I run "bin/ee log show bh-log-command-2.test --nginx --php" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile" exactly 4 times + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/php/error.log" + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/nginx/error.log" + And the command should still be running when the timeout expires + + Scenario Outline: A log type the site does not have fails with "No logs found!" + Given the site "" of type "" exists + When I run "bin/ee log show " with a timeout of 20 seconds + Then STDERR should contain "Error: No logs found!" + And STDOUT should not contain "watching logfile" + And the command should exit with code 1 + + Examples: + | site | type | flag | + | bh-log-command-1.test | html | --php | + | bh-log-command-1.test | html | --wp | + | bh-log-command-2.test | php | --wp | + + Scenario: Log files in subdirectories are discovered recursively + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-sub/bh-nested.log" contains: + """ + bh-nested-marker + """ + When I run "bin/ee log show bh-log-command-1.test --nginx" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/bh-sub/bh-nested.log" + And STDOUT should contain "bh-nested-marker" + And the command should still be running when the timeout expires + + Scenario: Files without a .log extension, including rotated logs, are ignored + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-notes.txt" contains: + """ + bh-notes-marker + """ + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-rotated.log.1" contains: + """ + bh-rotated-marker + """ + When I run "bin/ee log show bh-log-command-1.test --nginx" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile" exactly 2 times + And STDOUT should not contain "bh-notes" + And STDOUT should not contain "bh-rotated" + And the command should still be running when the timeout expires + + Scenario: The default line count is 10 + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-lines.log" contains: + """ + bh-line-01 + bh-line-02 + bh-line-03 + bh-line-04 + bh-line-05 + bh-line-06 + bh-line-07 + bh-line-08 + bh-line-09 + bh-line-10 + bh-line-11 + bh-line-12 + """ + When I run "bin/ee log show bh-log-command-1.test --nginx" with a timeout of 8 seconds + Then STDOUT should contain "bh-line-03" + And STDOUT should contain "bh-line-12" + And STDOUT should not contain "bh-line-02" + And STDOUT should not contain "bh-line-01" + And the command should still be running when the timeout expires + + Scenario: --n limits the initial lines + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-lines.log" contains: + """ + bh-line-01 + bh-line-02 + bh-line-03 + bh-line-04 + bh-line-05 + """ + When I run "bin/ee log show bh-log-command-1.test --nginx --n=2" with a timeout of 8 seconds + Then STDOUT should contain "bh-line-04" + And STDOUT should contain "bh-line-05" + And STDOUT should not contain "bh-line-03" + And the command should still be running when the timeout expires + + Scenario: --n=0 shows no existing lines but still follows + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-lines.log" contains: + """ + bh-line-01 + """ + When I run "bin/ee log show bh-log-command-1.test --nginx --n=0" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/bh-lines.log" + And STDOUT should not contain "bh-line-01" + And the command should still be running when the timeout expires + + Scenario: --n without a value warns and falls back to 10 lines + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-lines.log" contains: + """ + bh-line-01 + bh-line-02 + bh-line-03 + bh-line-04 + bh-line-05 + bh-line-06 + bh-line-07 + bh-line-08 + bh-line-09 + bh-line-10 + bh-line-11 + bh-line-12 + """ + When I run "bin/ee log show bh-log-command-1.test --nginx --n" with a timeout of 8 seconds + Then STDERR should contain "Warning: --n parameter needs a value" + And STDOUT should contain "bh-line-03" + And STDOUT should not contain "bh-line-02" + And the command should still be running when the timeout expires + + # Regression check (fixed in EasyEngine/log-command#8): --n was never validated and the exit status of tail was ignored, so an invalid + # line count printed tail's error but ee still exited 0. + Scenario: An invalid --n value fails with a non-zero exit code + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test --nginx --n=abc" with a timeout of 20 seconds + Then STDERR should contain "Invalid --n value 'abc'" + And STDOUT should not contain "watching logfile" + And the command should exit with a non-zero code + + # Regression check (fixed in EasyEngine/log-command#8): --n and the file list were interpolated unescaped into system( "tail -n $lines -f ..." ). + Scenario: --n is not interpreted by the shell + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test --nginx --n='1 -q /dev/null; echo BH_INJ_$((6*7)); #'" with a timeout of 8 seconds + Then STDOUT should not contain "BH_INJ_42" + And STDERR should contain "Invalid --n value" + And the command should exit with code 1 + + # Regression check (same fix): log file names were interpolated unescaped too; logs/php is writable by www-data in the php + # container, so a site's PHP code could plant a name that ran as root on the host when the admin followed the logs. + Scenario: A log file name is not interpreted by the shell + Given the site "bh-log-command-2.test" of type "php" exists + And the file "{SITES_DIR}/bh-log-command-2.test/logs/php/bh-inj$(echo${IFS}BH_FILE_INJ_$((6*7))).log" contains: + """ + bh-inj-marker + """ + When I run "bin/ee log show bh-log-command-2.test --php" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-2.test/logs/php/access.log" + And STDOUT should not contain "BH_FILE_INJ_42" + And STDERR should not contain "BH_FILE_INJ_42" + And STDOUT should contain "bh-inj-marker" + And the command should still be running when the timeout expires + + # Regression check (fixed in EasyEngine/log-command#8): the site's containers can write to its log directories and wp-content, and ee log show runs tail as + # root on the host, so a link planted there must not be followed out of the directory. + Scenario Outline: A log link that leaves the site's log directory is skipped + Given the site "" of type "" exists + And the file "{SITES_DIR}//" is a link to a file outside the site containing "bh-outside-marker" + When I run "bin/ee log show " with a timeout of 8 seconds + Then STDOUT should not contain "bh-outside-marker" + And STDOUT should not contain "watching logfile /" + And STDERR should contain "Warning: Skipping /: it is not a regular file inside the log directory." + And the command should still be running when the timeout expires + + Examples: + | site | type | link | flag | + | bh-log-command-2.test | php | logs/php/bh-outside.log | --php | + | bh-log-command-1.test | html | logs/nginx/bh-sub/bh-outside.log | --nginx | + | bh-log-command-3.test | wp | app/htdocs/wp-content/debug.log | --all | + + Scenario: A log link inside the site's log directory is still followed + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-real.log" contains: + """ + bh-inside-marker + """ + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-current.log" is a link to "bh-real.log" + When I run "bin/ee log show bh-log-command-1.test --nginx" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/bh-current.log" + And STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/bh-real.log" + And STDERR should not contain "Skipping" + And the command should still be running when the timeout expires + + Scenario: New lines are streamed while following + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-follow.log" contains: + """ + bh-follow-old + """ + When I run "bin/ee log show bh-log-command-1.test --nginx --n=0" with a timeout of 12 seconds while "bh-follow-new" is appended to the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-follow.log" once tail follows it + Then STDOUT should contain "bh-follow-new" + And STDOUT should not contain "bh-follow-old" + And the command should still be running when the timeout expires + + Scenario: A real request shows up in the nginx access log + Given the site "bh-log-command-1.test" of type "html" exists + Then a GET request to "http://127.0.0.1/bh-log-command-access-probe" with Host "bh-log-command-1.test" should return 404 + When I run "bin/ee log show bh-log-command-1.test --access --n=50" with a timeout of 8 seconds + Then STDOUT should contain "GET /bh-log-command-access-probe" + And the command should still be running when the timeout expires diff --git a/features/log.feature b/features/log.feature new file mode 100644 index 0000000..dd8dad1 --- /dev/null +++ b/features/log.feature @@ -0,0 +1,82 @@ +Feature: ee log command surface, site resolution and validation errors + + # `ee log show` ends in `tail -f`, so every invocation that can reach tail runs under a timeout; + # exit code 124 ("still running when the timeout expired") is the success signal for following. + + Scenario: ee log without a subcommand lists show + When I run 'bin/ee log' + Then STDOUT should contain "usage: ee log show" + And the command should exit with code 0 + + Scenario: ee help log show documents every option + When I run 'bin/ee help log show' + Then the command should exit with code 0 + And STDOUT should contain "ee log show [] [--n=] [--global] [--cli] [--all]" + And STDOUT should contain "[--nginx] [--php] [--wp] [--access] [--error]" + And STDOUT should contain "Monitor site or global logs." + + Scenario: log tail is not a registered subcommand + When I run 'bin/ee log tail' + Then STDERR should contain "Error: 'tail' is not a registered subcommand of 'log'. See 'ee help log' for available subcommands." + And the command should exit with code 1 + + Scenario: An unknown flag is rejected before any log is followed + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test --foo" with a timeout of 20 seconds + Then STDERR should contain "unknown --foo parameter" + And STDOUT should not contain "watching logfile" + And the command should exit with code 1 + + Scenario: A misspelt flag gets a suggestion + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test --ngix" with a timeout of 20 seconds + Then STDERR should contain "unknown --ngix parameter" + And STDERR should contain "Did you mean '--nginx'?" + And the command should exit with code 1 + + Scenario: Too many positional arguments are rejected + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test extra-arg" with a timeout of 20 seconds + Then STDERR should contain "Too many positional arguments: extra-arg" + And the command should exit with code 1 + + Scenario: Without a site name and outside a site directory the command explains how to call it + When I run "bin/ee log show" with a timeout of 20 seconds + Then STDERR should contain "Could not find the site you wish to run log show command on." + And STDERR should contain "ee log show " + And the command should exit with code 1 + + Scenario: An unknown site is reported + Given the site "bh-log-command-99.test" does not exist + When I run "bin/ee log show bh-log-command-99.test" with a timeout of 20 seconds + Then STDERR should contain "Could not find the site you wish to run log show command on." + And STDOUT should not contain "watching logfile" + And the command should exit with code 1 + + Scenario: A disabled site is refused + Given the site "bh-log-command-4.test" of type "html" exists + And the site "bh-log-command-4.test" is disabled + When I run "bin/ee log show bh-log-command-4.test" with a timeout of 20 seconds + Then STDERR should contain "Site bh-log-command-4.test is not enabled. Use `ee site enable bh-log-command-4.test` to enable it." + And STDOUT should not contain "watching logfile" + And the command should exit with code 1 + And the site "bh-log-command-4.test" should be disabled + + Scenario: The site name is detected from the current directory + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show" from "{SITES_DIR}/bh-log-command-1.test/app/htdocs" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" + And the command should still be running when the timeout expires + + Scenario: A wrong site name inside a site directory suggests the detected site + Given the site "bh-log-command-1.test" of type "html" exists + And the site "bh-log-command-99.test" does not exist + When I run "bin/ee log show bh-log-command-99.test" from "{SITES_DIR}/bh-log-command-1.test" with a timeout of 20 seconds + Then STDERR should contain "bh-log-command-99.test is not a valid site-name. Did you mean `ee log show bh-log-command-1.test`?" + And the command should exit with code 1 + + Scenario: A trailing slash on the site name is accepted + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test/" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" + And the command should still be running when the timeout expires