diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml new file mode 100644 index 000000000..0855ae834 --- /dev/null +++ b/.github/workflows/behat.yml @@ -0,0 +1,161 @@ +# Behat suite of EasyEngine core (features/), run 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: + COMPOSE_VERSION: v5.5.1 + # No public DNS (Let's Encrypt) or real backup storage on hosted runners. + EXCLUDE_TAGS: '~@le&&~@remote-real' + COMPOSE_INTERACTIVE_NO_CLI: 1 + # The suite's guards: a fresh runner has no ee.sqlite, and every lane may change global state. + EE_BEHAT_ALLOW_INIT: 1 + EE_BEHAT_DESTRUCTIVE: 1 + +jobs: + behat: + name: Behat ${{ matrix.lane }} (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + lane: [main, migration, uninstall] + include: + # Default profile plus @destructive; the first checked command initialises EasyEngine. + - lane: main + args: --tags="${EXCLUDE_TAGS}&&~@migration&&~@uninstall&&~@known-bug" + # The real upgrade pipeline and the self-uninstall need an initialised install, and a job each. + - lane: migration + args: -p migration + init: true + allow_migration: 1 + - lane: uninstall + args: -p uninstall + init: true + allow_uninstall: 1 + env: + EE_BEHAT_ALLOW_MIGRATION: ${{ matrix.allow_migration }} + EE_BEHAT_ALLOW_UNINSTALL: ${{ matrix.allow_uninstall }} + steps: + - name: Check out source code + uses: actions/checkout@v7 + + - 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)" + + - name: Install dependencies + run: | + # Every easyengine/* package on dev-develop, as core CI does on PRs. + php -r ' + $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"; + file_put_contents("composer.json", json_encode($j, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");' + composer update --prefer-dist --no-progress --no-interaction + # Some package commands shell out to `ee` from PATH. + sudo ln -sf "$PWD/bin/ee" /usr/local/bin/ee + cat VERSION + + - name: Initialise EasyEngine + if: matrix.init + run: | + # The first non-cli command runs the initial setup (DB, config, image pulls). + sudo -E bin/ee site list || true + sudo -E bin/ee cli info + # The first run's compose download has no --fail: a GitHub error page can replace the binary. + 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 + 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 + env: + # check-update calls the GitHub API; authenticated requests get a higher rate limit. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + mkdir -p "$RUNNER_TEMP/behat/junit" + # A CLI --tags replaces the profile filter, so the exclusions only apply to the --tags lanes. + sudo -E vendor/bin/behat --strict ${{ matrix.args }} \ + --format=progress --out=std \ + --format=junit --out="$RUNNER_TEMP/behat/junit" \ + --format=pretty --out="$RUNNER_TEMP/behat/pretty.log" + + - 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 docker images --format '{{.Repository}}:{{.Tag}}' > "$out/images.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/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 3884028ae..092b32367 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -150,22 +150,14 @@ jobs: run: | set -e cd $GITHUB_WORKSPACE + # site-command, site-type-php and site-type-wp run their own Behat suites (with Pebble lanes) on every PR. sudo -E ./vendor/bin/behat - sub_commands=( - vendor/easyengine/site-command/features - vendor/easyengine/site-type-php/features - vendor/easyengine/site-type-wp/features - ) - for command in "${sub_commands[@]}"; do - IFS='/' read -r -a array <<< "$command" - rm -rf features/* - rsync -av --delete $command/ features/ > /dev/null - for file in features/*.feature; do mv "$file" "${file%.feature}_${array[2]}.feature"; done - echo "Running tests for $command" - sudo -E ./vendor/bin/behat - done env: COMPOSE_INTERACTIVE_NO_CLI: 1 + # Core's suite refuses to initialise EasyEngine unless allowed; the runner starts without ee.sqlite. + EE_BEHAT_ALLOW_INIT: 1 + # check-update calls the GitHub API; authenticated requests get a higher rate limit. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Output logs if: ${{ always() }} diff --git a/behat.yml b/behat.yml new file mode 100644 index 000000000..e5bfb3d3d --- /dev/null +++ b/behat.yml @@ -0,0 +1,32 @@ +# The default profile skips everything that changes the live EasyEngine state. Those scenarios are also refused +# at runtime unless their env var is set (see FeatureContext::GUARDED_TAGS), because a CLI --tags option +# replaces these filters: +# @destructive EE_BEHAT_DESTRUCTIVE=1 DB version changes, the self-uninstall prompt +# @migration EE_BEHAT_ALLOW_MIGRATION=1 a real upgrade migration (image pulls, cron restart, log rotation) +# @uninstall EE_BEHAT_ALLOW_UNINSTALL=1 `ee cli self-uninstall --yes`; run it alone and last +# On a fresh machine (no /opt/easyengine/db/ee.sqlite) also set EE_BEHAT_ALLOW_INIT=1. +default: + gherkin: + filters: + tags: "~@destructive" + +destructive: + gherkin: + filters: + tags: "@destructive&&~@migration&&~@uninstall" + +migration: + gherkin: + filters: + tags: "@migration" + +uninstall: + gherkin: + filters: + tags: "@uninstall" + +# Everything that can run in one pass (the uninstall would break every scenario after it). +all: + gherkin: + filters: + tags: "~@migration&&~@uninstall" diff --git a/features/COVERAGE.md b/features/COVERAGE.md new file mode 100644 index 000000000..4b3de251a --- /dev/null +++ b/features/COVERAGE.md @@ -0,0 +1,240 @@ +# Behat coverage: easyengine (core) + +Core owns the runner (bootstrap, config, aliases, requirement checks, migration trigger, logging), the `cli` command group and `help`. Every other command belongs to a package suite; core only checks that each one has a help page and is registered. + +## Inventory and mapping + +### `ee cli version` + +| Item | Covered by | +|---|---| +| prints `EE ` | cli-version.feature: "cli version prints the source VERSION" | +| global `--version` rewrite to `cli version` | cli-version.feature: "The global --version flag ..." | +| no synopsis, so extra args/flags are ignored | cli-version.feature: "cli version takes no arguments ..." | +| `--help` rewrite to `help cli version` | cli-version.feature, help.feature subcommand outline | +| no requirement check (works without docker) | cli-version.feature: "cli version does not need docker ..." | +| from the phar | phar.feature: "The phar reports the source VERSION" | + +### `ee cli info [--format=list|json]` + +| Item | Covered by | +|---|---| +| list output, every row (OS, Shell, PHP binary/version, php.ini, EE root/vendor dir, phar path, packages dir, global/project config, version) | cli-info.feature: "cli info lists every diagnostic row" | +| `--format=json` keys and values | cli-info.feature: "cli info --format=json ..." | +| global config path from `EE_CONFIG_PATH` (json and list) | cli-info.feature: "cli info reports the global config ..." | +| project config path | config-file.feature: "cli info reports the project config file" | +| invalid `--format`, typo suggestion, extra positional | cli-info.feature (3 scenarios) | +| runs the migration check only when docker is up | migration.feature `@destructive`: "cli info skips the version check when docker is not available" and the newer/older version scenarios; requirements.feature: "cli info still works when docker is not available" | +| no migration when versions match | cli-info.feature: "cli info lists every diagnostic row" (no "Executing migrations") | +| phar values (`ee_dir_path` = `phar://ee.phar`, phar path) | phar.feature (the phar path is not covered yet: `EE_PHAR_PATH` is the working directory, its scenario comes back with the fix) | + +### `ee cli check-update [--patch] [--minor] [--major] [--field] [--fields] [--format]` + +| Item | Covered by | +|---|---| +| default table or "latest version" success | cli-update.feature `@network` | +| `--major`, `--minor`, `--patch` | cli-update.feature `@network` outline | +| `--format=json`, `--format=count` | cli-update.feature `@network` | +| invalid `--format` (validated before any request) | cli-update.feature | +| GitHub unreachable: clean error, exit 1 | cli-update.feature (proxy to a closed port, `no_proxy` cleared, no network needed) | +| HTTP error status (`GITHUB_TOKEN` sent, invalid token gives 401) | cli-update.feature `@network` | +| `--field`, `--fields`, `--format=csv|yaml`, precedence of several type flags | not covered: output depends on live release data; formatter covered by package suites | +| nightly branch (`-alpha-` versions, `NIGHTLY_VERSION`) | not covered: needs a nightly VERSION | + +### `ee cli update [--stable] [--nightly] [--yes]` + +| Item | Covered by | +|---|---| +| refuses to run from source (`You can only self-update Phar files.`) with every flag combination | cli-update.feature outline (4 rows) | +| real update (prompt, download, md5 check, child `cli info`, phar swap), macOS brew message, not-writable errors | not covered on purpose: an accepted prompt downloads a phar whose `cli info` runs the new version's migrations on the live DB; a declined prompt or an immutable (`chattr +i`) phar would be testable but a regression there would do exactly that; not on macOS | +| `auto_check_update()` on help (phar + tty only) | not covered: needs a tty and a writable phar | + +### `ee cli param-dump [--with-values] [--format=json|var_export]` + +| Item | Covered by | +|---|---| +| json spec with every global parameter and its fields | cli-dump.feature | +| `--format=var_export` | cli-dump.feature | +| `--with-values` from the global config, defaults, null values | cli-dump.feature | +| runtime flag overrides config (`--le-mail`) | cli-dump.feature, config-file.feature | +| invalid format, typo suggestion (`--with-value`) | cli-dump.feature | + +### `ee cli cmd-dump` and `ee site cmd-dump` + +| Item | Covered by | +|---|---| +| json tree, root name/description, all 12 top-level commands | cli-dump.feature | +| nested subcommands, synopsis, description | cli-dump.feature | +| `site cmd-dump` merges site types (`create --type=wp`) | cli-dump.feature | +| `site cmd-dump` bypasses the requirement checks (works without docker) | cli-dump.feature: "site cmd-dump works without docker"; migration.feature outline | + +### `ee cli completions --line --point [--shell=bash|zsh]` + +| Item | Covered by | +|---|---| +| top-level list, partial subcommand, all cli subcommands | cli-completions.feature | +| `--point` truncation | cli-completions.feature | +| `--shell=zsh` descriptions | cli-completions.feature | +| options of a leaf, flags with trailing space, already used options skipped | cli-completions.feature | +| `help` prefix | cli-completions.feature | +| site subcommands and `--type`-aware site options | cli-completions.feature | +| aliases (`ee @`, `ee @alias cli ...`) | cli-completions.feature | +| unknown command, invalid shell, missing `--line/--point` | cli-completions.feature, unknown-command.feature | +| unknown `--type` in site completions doesn't crash | not covered yet: it dies with an uncaught `ReflectionException` (its scenario comes back with the fix) | +| does not log to `ee.log` | cli-completions.feature | +| `` positional completion, site type from an existing site (`Site::find`) | not covered: no core command has a `` positional; needs a site | + +### `ee cli alias` / `ee cli aliases [--format=yaml|json]` and `@alias` execution + +| Item | Covered by | +|---|---| +| empty list (yaml, json) | cli-alias.feature | +| yaml listing with `@all` first | cli-alias.feature | +| json: only alias keys kept, relative `path` absolutized, groups | cli-alias.feature | +| `aliases` alias of `alias` | cli-alias.feature | +| invalid format | cli-alias.feature | +| `EE_RUNTIME_ALIAS` replaces file aliases, filters keys and names | cli-alias.feature | +| run through a local alias, `@all`, a group, a group with an unknown member, unknown alias suggestion, `@all` without aliases | cli-alias.feature | +| a group passes the command's flags to every member | cli-alias.feature | +| `@all`/group exit status, `@all` running group members twice | not covered yet: alias groups and `@all` ignore the children's exit codes (its 2 scenarios come back with the fix) | +| aliases with `ssh:`/`http:`, `--ssh`, `--http`, `ssh:` in config | cli-alias.feature: refused (fixed in v4.13.2 by EasyEngine/easyengine#1945; they ran locally before) | +| ssh schemes (docker, docker-compose, vagrant, ssh), `EE_SSH_PRE_CMD` | not covered: `run_ssh_command()` is dead code in EE; would need a remote host | + +### `ee cli has-command ...` + +| Item | Covered by | +|---|---| +| registered commands (quoted and split forms, packages' commands) exit 0 silently | cli-has-command.feature outline (8 rows) | +| unknown commands and empty name exit 1 silently | cli-has-command.feature outline (3 rows) | +| trailing words after a leaf (`cli version extra`) | not covered yet: `has-command` stops at the first leaf (its scenario comes back with the fix) | +| `site ` is "registered" (site is a leaf) | not covered: same first-leaf shortcut as the row above; the passing `site create` rows use the same shortcut | +| missing argument prints usage | cli-has-command.feature | +| disabled command reported as missing | cli-has-command.feature | + +### `ee cli self-uninstall [--yes]` + +| Item | Covered by | +|---|---| +| declining the prompt removes nothing | cli.feature `@destructive` | +| full uninstall (containers, networks, volumes, images, `/opt/easyengine`; site dirs not checked) | cli.feature `@destructive @uninstall` (the existing scenario) | +| refuses when docker is down (and its exit status) | requirements.feature (the exit status is not covered yet: the refusal exits 0, its scenario comes back with the fix) | +| phar self-delete | not covered: needs a phar install, and would delete it | + +### `ee help [...]` + +| Item | Covered by | +|---|---| +| `ee`, `ee help`, `ee --help` print the same top-level page, with global parameters | help.feature | +| every top-level command (12) and subcommand (54) has a help page equal to ` --help` | help.feature outlines | +| site type routing (`--type=html|wp|php`, default html, wp-only options, unknown type) | help.feature (the unknown-type hint is not covered yet: it repeats the bogus type, its scenario comes back with the fix) | +| unknown command/subcommand errors and suggestions | help.feature | +| `PAGER` (a `sed` pager that prefixes every line), examples section | help.feature | +| works without docker | requirements.feature | +| no output without `less` | not covered yet: nothing is printed and the exit status is 0 (its scenario comes back with the fix) | +| disabled command: error, left out of `ee help` and `ee help cli` lists | config-file.feature | + +### Global parameters and runner behaviour + +| Item | Covered by | +|---|---| +| `--debug`, `--debug` before the command, `--debug=`, unknown group, `debug:` in config | global-flags.feature | +| `EE_STRICT_ARGS_MODE` | global-flags.feature | +| color auto-detection (piped, `SHELL_PIPE=0`) | global-flags.feature | +| `--color`, `--no-color`, `color:` in config | not covered yet: colours and the logger are set up before the config is read (its 3 scenarios come back with the fix) | +| `--quiet`, `quiet:` in config (warnings, success), errors still shown | global-flags.feature (errors still shown; hiding warnings and success is not covered yet for the same reason, its 3 scenarios come back with the fix) | +| unknown parameter suggestions | cli-info.feature, cli-dump.feature | +| `--sites_path`, `--locale`, `--le-mail`, `--env`, `--wp-mail`, `--sysctl`, `--skip-tty`, `--custom-compose` | partly: only `--le-mail` (runtime) and `locale`/`le-mail`/`wp-mail` (file) are exercised (cli-dump.feature, config-file.feature); their effects belong to the package suites | +| unknown command/subcommand suggestions, shorthand map (`v`, `check`), no suggestion | unknown-command.feature | +| unknown top-level command runs the checks before the lookup | unknown-command.feature (guarded), migration.feature `@destructive`; the typo without docker is not covered yet: the checks run before the command lookup (its scenario comes back with the fix) | +| composite command usage, disabled subcommand hidden | unknown-command.feature, config-file.feature (the usage prefix with a disabled first subcommand is not covered yet: the prefix counts the skipped subcommand, its scenario comes back with the fix) | +| missing required parameters | unknown-command.feature | +| requirement checks: missing docker-compose, docker retry/backoff | requirements.feature | +| failed requirement exit status | not covered yet: a failed requirement exits 0 (its scenario comes back with the fix) | +| argv logged to `ee.log` with secrets | global-flags.feature, regular since the v4.13.2 fix (EasyEngine/easyengine#1944) | +| `EE_PACKAGES_DIR` (package autoloader loaded, commands registered) | config-file.feature | +| `BEHAT_RUN`, `EE_DISABLE_AUTO_CHECK_UPDATE`, `EE_AUTO_CHECK_UPDATE_DAYS`, `EE_PHP` | not covered: only affect error reporting or the phar+tty auto update (the phar scenarios set `EE_DISABLE_AUTO_CHECK_UPDATE`) | + +### Config files + +| Item | Covered by | +|---|---| +| `EE_CONFIG_PATH` file created with `locale`/`ee_installer_version` defaults | config-file.feature | +| existing values and extra keys kept when defaults are added | config-file.feature | +| path resolved before the defaults file exists (first run reads nothing) | config-file.feature | +| project config `ee.yml`, from a subdirectory, `ee.local.yml` priority, runtime override, none found | config-file.feature | +| `_: inherit:` | config-file.feature | +| `disabled_commands` (run, help, usage, alias, a whole namespace) | config-file.feature, cli-has-command.feature | +| per-command defaults (a `cli param-dump:` section), runtime flag wins | config-file.feature | +| unreadable/unwritable config errors | not covered: root bypasses file permissions; only reachable with `chattr +i` or `/proc` paths (fragile) | +| `_: merge:` for extra config, relative `inherit:` paths | not covered | + +### Migration trigger + +| Item | Covered by | +|---|---| +| cli/help/config/`site cmd-dump`/no-args never check (14 commands) | migration.feature `@destructive` outline (with a NEWER DB version, so a regression errors instead of migrating) | +| DB version newer: warning for `cli info`, error otherwise, before command lookup, a newer nightly DB version | migration.feature `@destructive` (3) | +| nightly suffix stripping itself | not covered: a distinguishing test would run a real migration when it fails | +| DB version older: full pipeline runs, version bumped | migration.feature `@destructive @migration` (never on a stack in use) | +| versions equal: nothing runs | cli-info.feature | +| fresh install (`ee successfully setup.`), nightly VERSION always migrating, 5 GB disk check, rollback | not covered: needs a wiped machine, a modified VERSION or a full disk; the rest of the upgrade pipeline needs live upgrade tests | + +### Phar + +| Item | Covered by | +|---|---| +| build (from a `--no-dev` copy of the checkout into `/var/tmp`), version, help, bundled packages registered, `cli info` root | phar.feature `@phar` | +| `EE_PHAR_PATH` is the working directory | not covered yet (its scenario comes back with the fix) | + + +### Coverage estimate + +The inventory has 107 rows: 93 covered, 1 partly covered and 13 not covered (about 87% of rows, about 78% when rows with several flags are counted per flag). What is left is the real self-update and phar self-delete (unsafe on a live stack), ssh aliases (never wired up; refused since EasyEngine/easyengine#1945), permission errors that root cannot hit, fresh-install and nightly migration paths, and output that depends on live GitHub release data. + +## How to run + +The suite runs from the core root as root. + +| Command | Runs | +|---|---| +| `vendor/bin/behat` | everything except `@destructive` (228 scenarios) | +| `EE_BEHAT_DESTRUCTIVE=1 vendor/bin/behat -p destructive` | `@destructive` without `@migration`/`@uninstall` (19 scenarios: DB version changes, the declined self-uninstall prompt) | +| `EE_BEHAT_DESTRUCTIVE=1 EE_BEHAT_ALLOW_MIGRATION=1 vendor/bin/behat -p migration` | the real upgrade migration (2 scenarios; never on a stack in use) | +| `EE_BEHAT_DESTRUCTIVE=1 EE_BEHAT_ALLOW_UNINSTALL=1 vendor/bin/behat -p uninstall` | `ee cli self-uninstall --yes` (1 scenario; run it alone and last) | +| `EE_BEHAT_DESTRUCTIVE=1 vendor/bin/behat -p all` | everything that can run in one pass (247 scenarios) | +| `vendor/bin/behat --tags='~@destructive&&~@network&&~@phar'` | fast, deterministic subset (217 scenarios) | + +A CLI `--tags` replaces the profile filter, so the tags alone can't keep the dangerous scenarios off: a `@BeforeScenario` hook refuses (fails) every `@destructive`, `@migration` or `@uninstall` scenario whose env var (`EE_BEHAT_DESTRUCTIVE`, `EE_BEHAT_ALLOW_MIGRATION`, `EE_BEHAT_ALLOW_UNINSTALL`) isn't set. On a machine without `/opt/easyengine/db/ee.sqlite` (a fresh CI runner) also set `EE_BEHAT_ALLOW_INIT=1`. + +Tags: `@destructive` (22, changes global state; restored where possible), `@migration` (2, the real upgrade pipeline), `@uninstall` (1, self-uninstall), `@network` (7, query the GitHub releases API; unauthenticated calls share GitHub's 60/hour limit), `@phar` (4, build a phar, about 1 min). No `@le` or `@remote` scenarios: core has no Let's Encrypt or backup code. + +No `@known-bug` scenarios ship here: the 17 scenarios written for open bugs (listed under "Known bugs" below) are held back until their fixes land and then return as regular scenarios. + +Safety rules built into the suite: + +- `Given no EasyEngine migration is pending` guards every scenario whose command reaches `maybe_trigger_migration()` with docker available (`cli info`, unknown top-level commands, phar `cli info`, the self-uninstall). It fails the scenario instead of running a migration when `options.version` differs from `VERSION`, when `VERSION` is a nightly, or when `/opt/easyengine/db/ee.sqlite` is missing (unless `EE_BEHAT_ALLOW_INIT=1`, which lets the first command initialise a fresh machine, as the old CI flow did). +- Only the `@migration` scenario writes an older `options.version`; the others use `99.0.0`, so a regression, or an `ee` run by cron or another user meanwhile, gets the "not running latest version" error instead of a migration. Changed options are restored in `@AfterScenario` and, if the run dies, by a shutdown function (Behat exits on Ctrl-C) and a SIGTERM handler when pcntl is available. +- Scratch files live under `/var/tmp/ee-behat-core`, which is created 0700 and refused when it is a symlink, owned by another user or writable by group or others. +- The phar is built from a `--no-dev` copy of the checkout under `/var/tmp/ee-behat-core/phar-/` and removed after the suite; the checkout, its `vendor/` autoloader and `/usr/local/bin/ee` are never touched, `cli update` is never run from the phar and `help` runs with `EE_DISABLE_AUTO_CHECK_UPDATE=1`. +- Config scenarios point `EE_CONFIG_PATH` at a per-scenario file under `/var/tmp/ee-behat-core/` (`{TMP}`), removed after the scenario; the real `/opt/easyengine/config/config.yml` is never written (scenarios without `EE_CONFIG_PATH` read it, so `debug:`, aliases or `disabled_commands` there would break exact-output checks). +- Requirement scenarios hide docker/docker-compose with a private `PATH` instead of stopping anything. +- The suite creates no sites and never deletes any. +- Commands are run through `EE\Process` directly, not `EE::launch()`, so the harness does not write test command lines into `ee.log`. + +Estimated runtime: default profile about 4 minutes (help outlines about 40 s, five 7 s docker-retry scenarios, about 1 min for the phar build, a few seconds of GitHub API calls). `-p migration` takes 1 to 3 min (image pulls, a cron container restart) and `-p uninstall` about 1 min, after which the next site command re-initialises EasyEngine and pulls about 8 GB of images. + +## Known bugs (scenarios held back until fixed) + +| Held-back scenario | Issue | +|---|---| +| requirements.feature: failed requirement exits non-zero | `check_requirements()` exits 0 after the error | +| global-flags.feature: `--color`, `--no-color`, `color:` config, `--quiet`, `quiet:` config, success with `--quiet` (6) | `php/bootstrap.php` sets up colours and the logger before reading the config | +| requirements.feature: help without `less` | nothing printed, exit 0 | +| cli-has-command.feature: trailing words after a leaf | `has-command` stops at the first leaf | +| config-file.feature: composite usage prefix | `CompositeCommand::show_usage()` counts the skipped disabled subcommand | +| phar.feature: phar path | `EE_PHAR_PATH` is `getcwd()` | +| unknown-command.feature: typo reported without docker | checks run before the command lookup | +| help.feature: hint for an unknown site type | site-command: the hint repeats the bogus type | +| requirements.feature: self-uninstall refusal exit status | the refusal exits 0 | +| cli-alias.feature: `@all` exit status, `@all` running group members twice (2) | the children's exit codes are ignored; `@all` also runs the group keys | +| cli-completions.feature: unknown `--type` in site completions | uncaught `ReflectionException` | diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 11dfd75db..fc6237e83 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -14,7 +14,18 @@ define( 'EE', true ); define( 'EE_VERSION', trim( file_get_contents( EE_ROOT . '/VERSION' ) ) ); -define( 'EE_ROOT_DIR', '/opt/easyengine' ); + +$root_alias = '/opt'; +if ( 'Darwin' === php_uname( 's' ) ) { + $sys_name = posix_getpwuid(posix_geteuid())['name']; + if ( ! empty( $sys_name ) ) { + $root_alias = '/Users/' . $sys_name; + } +} +// site-command's autoloaded site-command.php may already define it. +if ( ! defined( 'EE_ROOT_DIR' ) ) { + define( 'EE_ROOT_DIR', $root_alias . '/easyengine' ); +} require_once EE_ROOT . '/php/bootstrap.php'; @@ -45,36 +56,131 @@ /* 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\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\PyStringNode, + Behat\Gherkin\Node\TableNode; -use Behat\Gherkin\Node\PyStringNode; +define( 'EE_SITE_ROOT', EE_ROOT_DIR . '/sites' ); class FeatureContext implements Context { - public $command; + public $commands; public $webroot_path; + public $ee_path; + + /** + * Base directory of the per-scenario scratch directories ({TMP}). + */ + const TMP_BASE = '/var/tmp/ee-behat-core'; + + /** + * Tags that change the live EasyEngine state, each with the env var that must be set to run them. + * A CLI --tags replaces the behat.yml filters, so the tags alone can't keep these scenarios off. + */ + const GUARDED_TAGS = [ + 'destructive' => 'EE_BEHAT_DESTRUCTIVE', + 'migration' => 'EE_BEHAT_ALLOW_MIGRATION', + 'uninstall' => 'EE_BEHAT_ALLOW_UNINSTALL', + ]; + + /** + * Phar built by "ee phar is generated", shared by the whole run ({PHAR}). + * + * @var string|null + */ + private static $phar; + + /** + * Whether the shutdown/signal restore of changed options is registered. + * + * @var bool + */ + private $restore_registered = false; + + /** + * Per-scenario scratch directory, created on first use of {TMP}. + * + * @var string|null + */ + private $tmp_dir; + + /** + * Per-scenario random token ({RAND}). + * + * @var string|null + */ + private $rand; + + /** + * Original values of EasyEngine options changed by a scenario, restored afterwards. + * + * @var array + */ + private $saved_options = []; + + /** + * Container counts recorded by "I record the number of containers with label". + * + * @var array + */ + private $container_counts = []; + + /** + * Core container migrations written by a scenario (file => created its directory), removed afterwards. + * + * @var array + */ + private $probe_migrations = []; + + /** + * /etc/hosts before "Docker Hub is unreachable", restored afterwards. + * + * @var string|null + */ + private static $hosts_backup; + + /** + * 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'); - return 0; + // Building takes about a minute, so the phar is shared by the whole run. + if ( self::$phar && file_exists( self::$phar ) ) { + return; } - 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); + // Build from a private copy with a --no-dev autoloader: a phar built on a dev vendor requires + // require-dev files it doesn't contain. The checkout and /usr/local/bin/ee are never touched. + $root = realpath( EE_ROOT ); + $dir = self::tmp_base() . '/phar-' . getmypid(); + $src = "$dir/src"; + $phar = "$dir/ee.phar"; + $cmd = sprintf( + 'rm -rf %1$s && mkdir -p %1$s && tar -C %2$s --exclude=.git --exclude=./features --exclude="./*.phar" -cf - . | tar -C %1$s -xf -' + . ' && cd %1$s && COMPOSER_ALLOW_SUPERUSER=1 %3$scomposer dump-autoload --no-dev -q' + . ' && %4$s -dphar.readonly=0 utils/make-phar.php %5$s --quiet', + escapeshellarg( $src ), + escapeshellarg( $root ), + file_exists( "$root/composer.dev.json" ) ? 'COMPOSER=composer.dev.json ' : '', + escapeshellarg( PHP_BINARY ), + escapeshellarg( $phar ) + ); + exec( "( $cmd ) 2>&1", $output, $status ); + exec( 'rm -rf ' . escapeshellarg( $src ) ); + if ( 0 !== $status || ! file_exists( $phar ) ) { + throw new Exception( "Unable to generate the phar (exit $status):\n" . implode( "\n", $output ) ); } - - // Cache generaed phar as it is expensive to generate one - copy('ee.phar','ee-old.phar'); + self::$phar = $phar; } /** @@ -89,50 +195,273 @@ public function isInstalled($command) } /** - * @When I run :command + * @When /^I run (?|'(.*)'|"(.*)")$/ */ public function iRun($command) { - $this->command = EE::launch($command, false, true); + // Not EE::launch(): it would log the command line to ee.log, polluting log assertions. + $this->commands[] = \EE\Process::create( $this->replace_variables( $command ), null, null )->run(); } + /** * @Then return value should be 0 */ - public function returnCodeShouldBe0() + public function returnValueShouldBe0() + { + if ( 0 !== $this->commands[0]->return_code ) { + throw new Exception("Actual return code is not zero: \n" . $this->commands[0]); + } + } + + /** + * @Then return value of command :index should be 0 + */ + public function returnValueOfCommandShouldBe0($index) { - if ( 0 !== $this->command->return_code ) { - throw new Exception("Actual return code is not zero: \n" . $this->command); + if ( 0 !== $this->commands[ $index - 1 ]->return_code ) { + throw new Exception("Actual return code is not zero: \n" . $this->commands[ $index - 1 ]); } } /** - * @Then /(STDOUT|STDERR) should return exactly/ + * @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->command->stdout : $this->command->stderr; + $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 ( $this->replace_variables( $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); - $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; - if ( $expected_out !== trim($command_output)) { + if ( $this->replace_variables( $expected_output->getRaw() ) !== trim($command_output)) { throw new Exception("Actual output is:\n" . $command_output); } } /** - * @Then /(STDOUT|STDERR) should return something like/ + * @Then /^(STDOUT|STDERR) should return something like$/ */ public function stdoutShouldReturnSomethingLike($output_stream, PyStringNode $expected_output) { - $command_output = $output_stream === "STDOUT" ? $this->command->stdout : $this->command->stderr; + $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; + + $expected_out = $this->replace_variables( $expected_output->getRaw() ); + if (strpos($command_output, $expected_out) === 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; - $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; + $expected_out = $this->replace_variables( $expected_output->getRaw() ); if (strpos($command_output, $expected_out) === 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 -T --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); + $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); + $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); + $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 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"); + } + } + /** * @Then ee should be deleted */ @@ -142,6 +471,12 @@ public function eeShouldBeDeleted() if( trim($result->stdout) !== '0' ) { throw new Exception("All containers have not been removed."); } + foreach ( [ 'network', 'volume', 'image' ] as $object ) { + $left = trim( EE::launch( "docker $object ls -q -f label=org.label-schema.vendor=EasyEngine | wc -l", false, true )->stdout ); + if ( '0' !== $left ) { + throw new Exception( "$left EasyEngine docker {$object}(s) have not been removed." ); + } + } $home = getenv('HOME'); if(file_exists("$home/.ee/")){ throw new Exception("~/.ee/ has not been removed"); @@ -154,16 +489,766 @@ public function eeShouldBeDeleted() } } + /* + * --------------------------------------------------------------------- + * Shared generic steps (easyengine core suite). + * + * Output steps act on the LAST command run in the scenario. Step strings + * support the placeholders {TMP} (per-scenario scratch dir under + * /var/tmp), {EE} (absolute bin/ee), {EE_ROOT}, {EE_ROOT_DIR}, + * {EE_VERSION}, {PHP} (PHP binary running Behat), {PHAR} (the built phar) and {RAND}. + * --------------------------------------------------------------------- + */ + + /** + * @Then the command should exit with code :code + */ + public function theCommandShouldExitWithCode( $code ) + { + $last = $this->last_command(); + if ( (int) $code !== $last->return_code ) { + throw new Exception( "Expected exit code $code, got {$last->return_code}.\n" . $this->describe( $last ) ); + } + } + + /** + * @Then the command should succeed + */ + public function theCommandShouldSucceed() + { + $this->theCommandShouldExitWithCode( 0 ); + } + + /** + * @Then the command should fail + */ + public function theCommandShouldFail() + { + $last = $this->last_command(); + if ( 0 === $last->return_code ) { + throw new Exception( "Expected a non-zero exit code, got 0.\n" . $this->describe( $last ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should ((?:not )?)contain "(.*)"$/ + */ + public function streamShouldContainText( $stream, $negate, $text ) + { + $this->assert_contains( $stream, $this->replace_variables( $text ), '' !== $negate ); + } + + /** + * @Then /^(STDOUT|STDERR) should ((?:not )?)contain:$/ + */ + public function streamShouldContainBlock( $stream, $negate, PyStringNode $text ) + { + $this->assert_contains( $stream, $this->replace_variables( $text->getRaw() ), '' !== $negate ); + } + + /** + * @Then /^(STDOUT|STDERR) should be:$/ + */ + public function streamShouldBe( $stream, PyStringNode $expected ) + { + $actual = trim( $this->stream_output( $stream ) ); + $expected = trim( $this->replace_variables( $expected->getRaw() ) ); + if ( $expected !== $actual ) { + throw new Exception( "$stream is not exactly:\n$expected\n\n" . $this->describe( $this->last_command() ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should be empty$/ + */ + public function streamShouldBeEmpty( $stream ) + { + if ( '' !== trim( $this->stream_output( $stream ) ) ) { + throw new Exception( "$stream is not empty.\n" . $this->describe( $this->last_command() ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should ((?:not )?)match "(.*)"$/ + */ + public function streamShouldMatch( $stream, $negate, $regex ) + { + $regex = $this->replace_variables( $regex ); + $matched = preg_match( $regex, $this->stream_output( $stream ) ); + if ( false === $matched ) { + throw new Exception( "Invalid regex: $regex" ); + } + if ( ( '' === $negate ) !== ( 1 === $matched ) ) { + throw new Exception( "$stream should " . $negate . "match $regex\n" . $this->describe( $this->last_command() ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should ((?:not )?)contain ANSI color codes$/ + */ + public function streamShouldContainAnsiCodes( $stream, $negate ) + { + $has_codes = 1 === preg_match( '/\x1b\[[0-9;]*m/', $this->stream_output( $stream ) ); + if ( ( '' === $negate ) !== $has_codes ) { + throw new Exception( "$stream should " . $negate . "contain ANSI color codes.\n" . $this->describe( $this->last_command(), true ) ); + } + } + + /** + * @Then STDOUT of command :first should be identical to STDOUT of command :second + */ + public function stdoutOfCommandsShouldBeIdentical( $first, $second ) + { + $a = $this->command_at( $first ); + $b = $this->command_at( $second ); + if ( $a->stdout !== $b->stdout ) { + throw new Exception( "STDOUT differs.\nCommand $first:\n" . $this->describe( $a ) . "\nCommand $second:\n" . $this->describe( $b ) ); + } + } + + /** + * @Then STDOUT should be valid json + */ + public function stdoutShouldBeValidJson() + { + $this->decoded_stdout(); + } + + /** + * @Then STDOUT should be empty or valid json + */ + public function stdoutShouldBeEmptyOrValidJson() + { + if ( '' !== trim( $this->last_command()->stdout ) ) { + $this->decoded_stdout(); + } + } + + /** + * @Then the json in STDOUT should have the keys :keys + */ + public function theJsonShouldHaveTheKeys( $keys ) + { + $this->theJsonAtShouldHaveTheKeys( '', $keys ); + } + + /** + * @Then the json in STDOUT at :path should have the keys :keys + */ + public function theJsonAtShouldHaveTheKeys( $path, $keys ) + { + $node = $this->single_json_node( $path ); + $missing = []; + foreach ( array_map( 'trim', explode( ',', $this->replace_variables( $keys ) ) ) as $key ) { + if ( ! is_array( $node ) || ! array_key_exists( $key, $node ) ) { + $missing[] = $key; + } + } + if ( $missing ) { + throw new Exception( 'Missing keys at "' . $path . '": ' . implode( ', ', $missing ) . "\n" . $this->describe( $this->last_command() ) ); + } + } + + /** + * @Then the json in STDOUT at :path should be :value + */ + public function theJsonAtShouldBe( $path, $value ) + { + $actual = self::json_scalar( $this->single_json_node( $path ) ); + $expected = $this->replace_variables( $value ); + if ( $expected !== $actual ) { + throw new Exception( "Expected \"$path\" to be \"$expected\", got \"$actual\".\n" . $this->describe( $this->last_command() ) ); + } + } + + /** + * @Then the json in STDOUT at :path should match :regex + */ + public function theJsonAtShouldMatch( $path, $regex ) + { + $actual = self::json_scalar( $this->single_json_node( $path ) ); + if ( 1 !== preg_match( $this->replace_variables( $regex ), $actual ) ) { + throw new Exception( "Expected \"$path\" to match $regex, got \"$actual\".\n" . $this->describe( $this->last_command() ) ); + } + } + + /** + * @Then /^the json in STDOUT at "([^"]*)" should ((?:not )?)contain "([^"]*)"$/ + */ + public function theJsonAtShouldContain( $path, $negate, $value ) + { + $nodes = $this->json_nodes( $path ); + // A single array node means "this list contains"; several nodes come from a "*" path. + if ( 1 === count( $nodes ) && is_array( $nodes[0] ) ) { + $nodes = array_values( $nodes[0] ); + } + $value = $this->replace_variables( $value ); + $values = array_map( array( __CLASS__, 'json_scalar' ), $nodes ); + if ( ( '' === $negate ) !== in_array( $value, $values, true ) ) { + throw new Exception( "Expected \"$path\" to " . $negate . "contain \"$value\". Values: " . implode( ', ', $values ) ); + } + } + + /** + * @Given a file :path with: + */ + public function aFileWith( $path, PyStringNode $content ) + { + $path = $this->replace_variables( $path ); + if ( ! is_dir( dirname( $path ) ) ) { + mkdir( dirname( $path ), 0755, true ); + } + if ( false === file_put_contents( $path, $this->replace_variables( $content->getRaw() ) . "\n" ) ) { + throw new Exception( "Could not write $path" ); + } + } + + /** + * @Given the directory :path exists + */ + public function theDirectoryExists( $path ) + { + $path = $this->replace_variables( $path ); + if ( ! is_dir( $path ) && ! mkdir( $path, 0755, true ) ) { + throw new Exception( "Could not create $path" ); + } + } + + /** + * @Given the file :path does not exist + */ + public function theFileDoesNotExist( $path ) + { + $path = $this->replace_variables( $path ); + if ( file_exists( $path ) && ! unlink( $path ) ) { + throw new Exception( "Could not delete $path" ); + } + } + + /** + * @Given the executable :name is linked into :dir + */ + public function theExecutableIsLinkedInto( $name, $dir ) + { + $dir = $this->replace_variables( $dir ); + $this->theDirectoryExists( $dir ); + $binary = trim( (string) shell_exec( 'command -v ' . escapeshellarg( $name ) ) ); + if ( '' === $binary ) { + throw new Exception( "$name is not installed." ); + } + if ( ! file_exists( "$dir/$name" ) && ! symlink( $binary, "$dir/$name" ) ) { + throw new Exception( "Could not link $binary into $dir" ); + } + } + + /** + * @Then the file :path should exist + */ + public function theFileShouldExist( $path ) + { + $path = $this->replace_variables( $path ); + if ( ! file_exists( $path ) ) { + throw new Exception( "$path does not exist." ); + } + } + + /** + * @Then the file :path should not exist + */ + public function theFileShouldNotExist( $path ) + { + $path = $this->replace_variables( $path ); + if ( file_exists( $path ) ) { + throw new Exception( "$path exists." ); + } + } + + /** + * @Then the file :path should contain :text + */ + public function theFileShouldContain( $path, $text ) + { + $this->assert_file_contains( $path, $text, false ); + } + + /** + * @Then the file :path should not contain :text + */ + public function theFileShouldNotContain( $path, $text ) + { + $this->assert_file_contains( $path, $text, true ); + } + + /** + * @Given no EasyEngine migration is pending + */ + public function noEasyEngineMigrationIsPending() + { + // Commands that check requirements (e.g. `cli info`) would otherwise run a real migration. + if ( false !== strpos( EE_VERSION, 'nightly' ) ) { + throw new Exception( 'VERSION ' . EE_VERSION . ' is a nightly: every checked command would run the migration. Refusing to continue.' ); + } + if ( ! file_exists( self::db_path() ) ) { + if ( getenv( 'EE_BEHAT_ALLOW_INIT' ) ) { + return; + } + throw new Exception( self::db_path() . ' does not exist: the next checked command would initialise EasyEngine. Set EE_BEHAT_ALLOW_INIT=1 to allow that.' ); + } + $db_version = self::get_option( 'version' ); + if ( EE_VERSION !== $db_version ) { + throw new Exception( "DB version '$db_version' differs from VERSION '" . EE_VERSION . "': a checked command would run the migration. Refusing to continue." ); + } + } + + /** + * @Given the EasyEngine option :key is set to :value + */ + public function theEasyEngineOptionIsSetTo( $key, $value ) + { + if ( ! array_key_exists( $key, $this->saved_options ) ) { + $this->saved_options[ $key ] = self::get_option( $key ); + } + if ( ! $this->restore_registered ) { + // Also restore when the run dies mid-scenario (Behat's SIGINT handler exits, fatals, SIGTERM). + register_shutdown_function( [ $this, 'restoreOptions' ] ); + if ( function_exists( 'pcntl_signal' ) ) { + pcntl_async_signals( true ); + pcntl_signal( SIGTERM, function () { + exit( 143 ); + } ); + } + $this->restore_registered = true; + } + self::set_option( $key, $this->replace_variables( $value ) ); + } + + /** + * Without a pull, a blocked registry can't fail the image migration: if every image it would pull + * already runs (or is recorded) at its target tag, record a stale tag for one that isn't running. + * + * @Given the next image migration pulls at least one image + */ + public function theNextImageMigrationPullsAtLeastOneImage() + { + $running = []; + foreach ( array_filter( explode( "\n", (string) shell_exec( "docker ps --format '{{.Image}}'" ) ) ) as $image ) { + $parts = explode( ':', $image, 2 ); + $running[ $parts[0] ] = isset( $parts[1] ) ? $parts[1] : null; + } + $free = null; + foreach ( json_decode( file_get_contents( EE_ROOT . '/img-versions.json' ), true ) as $image => $version ) { + // Same exclusions as Containers::start_container_migration(). + if ( preg_match( '#^easyengine/(php\d|newrelic-daemon)#', $image ) ) { + continue; + } + // Running containers take precedence over the options table, as in the migration. + $current = array_key_exists( $image, $running ) ? $running[ $image ] : self::get_option( $image ); + if ( $current !== $version ) { + return; + } + if ( null === $free && ! array_key_exists( $image, $running ) ) { + $free = $image; + } + } + if ( null === $free ) { + throw new Exception( 'Every image the migration could pull runs at its target tag, so no pull can fail.' ); + } + $this->theEasyEngineOptionIsSetTo( $free, 'v0.0.0-behat-stale' ); + } + + /** + * @Then the EasyEngine option :key should be :value + */ + public function theEasyEngineOptionShouldBe( $key, $value ) + { + $actual = self::get_option( $key ); + $expected = $this->replace_variables( $value ); + if ( $expected !== $actual ) { + throw new Exception( "Expected option '$key' to be '$expected', got " . var_export( $actual, true ) ); + } + } + + /** + * @Given I record the number of containers with label :label + */ + public function iRecordTheNumberOfContainersWithLabel( $label ) + { + $this->container_counts[ $label ] = self::count_containers( $label ); + } + + /** + * @Then the number of containers with label :label should be unchanged + */ + public function theNumberOfContainersWithLabelShouldBeUnchanged( $label ) + { + if ( ! isset( $this->container_counts[ $label ] ) ) { + throw new Exception( "No recorded container count for $label." ); + } + $now = self::count_containers( $label ); + if ( $now !== $this->container_counts[ $label ] ) { + throw new Exception( "Containers with label $label: {$this->container_counts[ $label ]} before, $now now." ); + } + } + + /** + * @Given the core container migration :file with class :class logs its up and down calls to :log + */ + public function theCoreContainerMigrationLogsTo( $file, $class, $log ) + { + $dir = EE_ROOT . '/migrations/container'; + $created = ! is_dir( $dir ); + if ( $created && ! mkdir( $dir, 0755, true ) ) { + throw new Exception( "Could not create $dir" ); + } + $this->probe_migrations[ $file ] = $created; + $log = var_export( $this->replace_variables( $log ), true ); + $code = "prepare( "SELECT COUNT(*) FROM migrations WHERE migration = ? AND type = 'container'" ); + $query->execute( [ $file ] ); + $count = (int) $query->fetchColumn(); + if ( $negate ? 0 !== $count : 1 !== $count ) { + throw new Exception( "Expected $file to " . ( $negate ? 'not ' : '' ) . "be recorded once, found $count rows." ); + } + } + + /** + * @Given Docker Hub is unreachable + */ + public function dockerHubIsUnreachable() + { + if ( null === self::$hosts_backup ) { + self::$hosts_backup = file_get_contents( '/etc/hosts' ); + register_shutdown_function( [ self::class, 'restoreHosts' ] ); + } + file_put_contents( '/etc/hosts', self::$hosts_backup . "\n127.0.0.1 registry-1.docker.io auth.docker.io index.docker.io\n" ); + // dockerd's Go resolver re-reads /etc/hosts at most every 5 s. + sleep( 6 ); + exec( 'timeout 60 docker pull -q busybox:1.36 2>&1', $out, $rc ); + if ( 0 === $rc ) { + exec( 'docker rmi busybox:1.36' ); + self::restoreHosts(); + throw new Exception( 'Blocking Docker Hub in /etc/hosts had no effect (a registry mirror?).' ); + } + } + + /** + * @When Docker Hub is reachable again + */ + public function dockerHubIsReachableAgain() + { + self::restoreHosts(); + sleep( 6 ); + } + + /** + * Puts back /etc/hosts saved by "Docker Hub is unreachable". + */ + public static function restoreHosts() + { + if ( null !== self::$hosts_backup ) { + file_put_contents( '/etc/hosts', self::$hosts_backup ); + self::$hosts_backup = null; + } + } + + /** + * @BeforeScenario + */ + public function refuseGuardedScenarios( BeforeScenarioScope $scope ) + { + $tags = array_merge( $scope->getFeature()->getTags(), $scope->getScenario()->getTags() ); + foreach ( self::GUARDED_TAGS as $tag => $env ) { + if ( in_array( $tag, $tags, true ) && ! getenv( $env ) ) { + throw new Exception( "Refusing to run a @$tag scenario: it changes the live EasyEngine state. Set $env=1 to allow it." ); + } + } + } + + /** + * Puts back the options changed by "the EasyEngine option :key is set to :value". + */ + public function restoreOptions() + { + foreach ( $this->saved_options as $key => $value ) { + if ( false === $value ) { + self::delete_option( $key ); + } else { + self::set_option( $key, $value ); + } + } + $this->saved_options = []; + } + + /** + * @AfterScenario + */ + public function cleanupScenario(AfterScenarioScope $scope) + { + self::restoreHosts(); + foreach ( $this->probe_migrations as $file => $created_dir ) { + @unlink( EE_ROOT . "/migrations/container/$file" ); + if ( $created_dir ) { + @rmdir( EE_ROOT . '/migrations/container' ); + } + if ( file_exists( self::db_path() ) ) { + self::db()->prepare( 'DELETE FROM migrations WHERE migration = ?' )->execute( [ $file ] ); + } + } + $this->probe_migrations = []; + $this->restoreOptions(); + $this->container_counts = []; + $this->commands = []; + $this->rand = null; + + if ( $this->tmp_dir && 0 === strpos( $this->tmp_dir, self::TMP_BASE . '/' ) ) { + exec( 'rm -rf ' . escapeshellarg( $this->tmp_dir ) ); + } + $this->tmp_dir = null; + + chdir($this->ee_path); + } + + /** + * @AfterSuite + */ + public static function removePhar() + { + if ( self::$phar ) { + exec( 'rm -rf ' . escapeshellarg( dirname( self::$phar ) ) ); + self::$phar = null; + } + } + + private function replace_variables( $string ) + { + $map = [ + '{EE}' => realpath( EE_ROOT ) . '/bin/ee', + '{EE_ROOT_DIR}' => EE_ROOT_DIR, + '{EE_ROOT}' => realpath( EE_ROOT ), + '{EE_VERSION}' => EE_VERSION, + '{PHP}' => PHP_BINARY, + ]; + if ( false !== strpos( $string, '{TMP}' ) ) { + $map['{TMP}'] = $this->tmp_dir(); + } + if ( false !== strpos( $string, '{PHAR}' ) ) { + if ( null === self::$phar ) { + throw new Exception( '{PHAR} needs "Given ee phar is generated" first.' ); + } + $map['{PHAR}'] = self::$phar; + } + if ( false !== strpos( $string, '{RAND}' ) ) { + if ( null === $this->rand ) { + $this->rand = bin2hex( random_bytes( 6 ) ); + } + $map['{RAND}'] = $this->rand; + } + + return strtr( $string, $map ); + } + + private function tmp_dir() + { + if ( null === $this->tmp_dir ) { + $this->tmp_dir = self::tmp_base() . '/' . uniqid( 'scenario-', true ); + if ( ! mkdir( $this->tmp_dir, 0755 ) ) { + throw new Exception( "Could not create {$this->tmp_dir}" ); + } + } + + return $this->tmp_dir; + } + + /** + * TMP_BASE is in world-writable /var/tmp, so it is only used as a real directory owned by the running + * user and not writable by others; anything else could redirect root's writes and rm -rf through symlinks. + */ + private static function tmp_base() + { + $base = self::TMP_BASE; + if ( ! file_exists( $base ) && ! is_link( $base ) ) { + $umask = umask( 077 ); + @mkdir( $base, 0700 ); + umask( $umask ); + } + clearstatcache(); + $stat = @lstat( $base ); + if ( false === $stat || is_link( $base ) || ! is_dir( $base ) ) { + throw new Exception( "$base is not a real directory." ); + } + if ( $stat['uid'] !== posix_geteuid() || ( $stat['mode'] & 0022 ) ) { + throw new Exception( "$base must be owned by the running user and not writable by group or others." ); + } + + return $base; + } + + private function last_command() + { + if ( empty( $this->commands ) ) { + throw new Exception( 'No command has been run in this scenario.' ); + } + + return end( $this->commands ); + } + + private function command_at( $index ) + { + if ( ! isset( $this->commands[ $index - 1 ] ) ) { + throw new Exception( "Command $index has not been run in this scenario." ); + } + + return $this->commands[ $index - 1 ]; + } + + private function stream_output( $stream ) + { + $last = $this->last_command(); + + return 'STDOUT' === $stream ? $last->stdout : $last->stderr; + } + + private function describe( $run, $escape = false ) + { + $show = function ( $text ) use ( $escape ) { + return $escape ? addcslashes( $text, "\033" ) : $text; + }; + + return "Command: {$run->command}\nExit code: {$run->return_code}\nSTDOUT:\n" . $show( $run->stdout ) . "\nSTDERR:\n" . $show( $run->stderr ); + } + + private function assert_contains( $stream, $text, $negate ) + { + $found = false !== strpos( $this->stream_output( $stream ), $text ); + if ( $found === $negate ) { + throw new Exception( "$stream should " . ( $negate ? 'not ' : '' ) . "contain:\n$text\n\n" . $this->describe( $this->last_command() ) ); + } + } + + private function assert_file_contains( $path, $text, $negate ) + { + $path = $this->replace_variables( $path ); + $text = $this->replace_variables( $text ); + if ( ! is_readable( $path ) ) { + throw new Exception( "$path is not readable." ); + } + $found = false !== strpos( file_get_contents( $path ), $text ); + if ( $found === $negate ) { + throw new Exception( "$path should " . ( $negate ? 'not ' : '' ) . "contain \"$text\". Content:\n" . file_get_contents( $path ) ); + } + } + + private function decoded_stdout() + { + $stdout = $this->last_command()->stdout; + $decoded = json_decode( $stdout, true ); + if ( null === $decoded && 'null' !== trim( $stdout ) ) { + throw new Exception( 'STDOUT is not valid json: ' . json_last_error_msg() . "\n" . $this->describe( $this->last_command() ) ); + } + + return $decoded; + } + /** - * @AfterFeature + * Resolves a dot path: "key", "*" (every element) or "field=value" (elements whose field matches). */ - public static function cleanup(AfterFeatureScope $scope) + private function json_nodes( $path ) { - if(file_exists('ee.phar')) { - unlink('ee.phar'); + $nodes = [ $this->decoded_stdout() ]; + $path = $this->replace_variables( $path ); + foreach ( '' === $path ? [] : explode( '.', $path ) as $part ) { + $next = []; + foreach ( $nodes as $node ) { + if ( ! is_array( $node ) ) { + continue; + } + if ( '*' === $part ) { + $next = array_merge( $next, array_values( $node ) ); + } elseif ( false !== strpos( $part, '=' ) ) { + list( $field, $value ) = explode( '=', $part, 2 ); + foreach ( $node as $element ) { + if ( is_array( $element ) && isset( $element[ $field ] ) && (string) $element[ $field ] === $value ) { + $next[] = $element; + } + } + } elseif ( array_key_exists( $part, $node ) ) { + $next[] = $node[ $part ]; + } + } + $nodes = $next; } - if(file_exists('ee-old.phar')) { - unlink('ee-old.phar'); + + return $nodes; + } + + private function single_json_node( $path ) + { + $nodes = $this->json_nodes( $path ); + if ( 1 !== count( $nodes ) ) { + throw new Exception( 'Expected one value at "' . $path . '", found ' . count( $nodes ) . ".\n" . $this->describe( $this->last_command() ) ); + } + + return $nodes[0]; + } + + private static function json_scalar( $value ) + { + return is_string( $value ) ? $value : json_encode( $value, JSON_UNESCAPED_SLASHES ); + } + + private static function db_path() + { + return EE_ROOT_DIR . '/db/ee.sqlite'; + } + + private static function db() + { + if ( ! file_exists( self::db_path() ) ) { + throw new Exception( self::db_path() . ' does not exist.' ); } + $pdo = new PDO( 'sqlite:' . self::db_path() ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + + return $pdo; + } + + private static function get_option( $key ) + { + $query = self::db()->prepare( 'SELECT value FROM options WHERE "key" = ?' ); + $query->execute( [ $key ] ); + + return $query->fetchColumn(); + } + + private static function set_option( $key, $value ) + { + $db = self::db(); + if ( false === self::get_option( $key ) ) { + $db->prepare( 'INSERT INTO options ("key", value) VALUES (?, ?)' )->execute( [ $key, $value ] ); + } else { + $db->prepare( 'UPDATE options SET value = ? WHERE "key" = ?' )->execute( [ $value, $key ] ); + } + } + + private static function delete_option( $key ) + { + self::db()->prepare( 'DELETE FROM options WHERE "key" = ?' )->execute( [ $key ] ); + } + + private static function count_containers( $label ) + { + return (int) trim( (string) shell_exec( 'docker ps -aq --filter ' . escapeshellarg( 'label=' . $label ) . ' | wc -l' ) ); } } diff --git a/features/bootstrap/support.php b/features/bootstrap/support.php index ed9a5b565..a5f1a1e67 100644 --- a/features/bootstrap/support.php +++ b/features/bootstrap/support.php @@ -81,13 +81,27 @@ function compareContents( $expected, $actual ) { if ( is_object( $expected ) ) { foreach ( get_object_vars( $expected ) as $name => $value ) { - if ( ! compareContents( $value, $actual->$name ) ) { + 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 ( ! compareContents( $value, $actual[$key] ) ) { + if ( ! array_key_exists( $key, $actual ) || ! compareContents( $value, $actual[ $key ] ) ) { return false; } } diff --git a/features/cli-alias.feature b/features/cli-alias.feature new file mode 100644 index 000000000..39db9c35c --- /dev/null +++ b/features/cli-alias.feature @@ -0,0 +1,276 @@ +Feature: ee cli alias and @alias execution + + Scenario: No aliases are listed for a config without aliases + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli alias' + Then the command should exit with code 0 + And STDOUT should be: + """ + --- + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli alias --format=json' + Then STDOUT should be: + """ + [] + """ + + Scenario: Aliases from the config file are listed with @all first + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + @bh-one: + path: /var/tmp + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli alias' + Then the command should exit with code 0 + And STDOUT should be: + """ + --- + @all: Run command against every registered alias. + @bh-one: + path: /var/tmp + """ + + Scenario: Alias json output keeps only alias keys and absolutizes relative paths + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + @bh-one: + path: /var/tmp + bogus: ignored + @bh-rel: + path: relative/dir + @bh-group: + - @bh-one + - @bh-rel + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli alias --format=json' + Then the command should exit with code 0 + And the json in STDOUT should have the keys "@all, @bh-one, @bh-rel, @bh-group" + And the json in STDOUT at "@all" should be "Run command against every registered alias." + And the json in STDOUT at "@bh-one" should be '{"path":"/var/tmp"}' + And the json in STDOUT at "@bh-rel.path" should be "{TMP}/relative/dir" + And the json in STDOUT at "@bh-group" should be '["@bh-one","@bh-rel"]' + + Scenario: cli aliases is an alias of cli alias + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + @bh-one: + path: /var/tmp + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli alias --format=json' + And I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli aliases --format=json' + Then the command should exit with code 0 + And STDOUT of command 1 should be identical to STDOUT of command 2 + + Scenario: cli alias rejects an unsupported format + When I run 'bin/ee cli alias --format=table' + Then the command should exit with code 1 + And STDERR should contain "Invalid value specified for 'format'" + + Scenario: Aliases can be provided at runtime with EE_RUNTIME_ALIAS + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + @bh-file: + path: /var/tmp + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml EE_RUNTIME_ALIAS='{"@bh-rt":{"path":"/srv","bogus":"x"},"not-an-alias":{"path":"/x"}}' bin/ee cli alias --format=json' + Then the command should exit with code 0 + And the json in STDOUT should have the keys "@all, @bh-rt" + And the json in STDOUT at "@bh-rt" should be '{"path":"/srv"}' + And STDOUT should not contain "@bh-file" + And STDOUT should not contain "not-an-alias" + + Scenario: A command runs locally through an alias without ssh + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + @bh-one: + path: /var/tmp + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee @bh-one cli version' + Then the command should exit with code 0 + And STDOUT should be: + """ + EE {EE_VERSION} + """ + + Scenario: @all runs the command once per alias + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + @bh-one: + path: /var/tmp + @bh-two: + path: /var/tmp + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee @all cli version' + Then the command should exit with code 0 + And STDOUT should be: + """ + @bh-one + EE {EE_VERSION} + @bh-two + EE {EE_VERSION} + """ + + Scenario: An alias group runs the command for each member + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + @bh-one: + path: /var/tmp + @bh-two: + path: /var/tmp + @bh-group: + - @bh-two + - @bh-one + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee @bh-group cli version' + Then the command should exit with code 0 + And STDOUT should be: + """ + @bh-two + EE {EE_VERSION} + @bh-one + EE {EE_VERSION} + """ + + Scenario: A group with an unknown member is rejected + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + @bh-one: + path: /var/tmp + @bh-broken: + - @bh-one + - @bh-missing + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee @bh-broken cli version' + Then the command should exit with code 1 + And STDERR should contain "Error: Group '@bh-broken' contains one or more invalid aliases: @bh-missing" + + Scenario: An unknown alias fails with a suggestion + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + @bh-one: + path: /var/tmp + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee @bh-onee cli version' + Then the command should exit with code 1 + And STDERR should be: + """ + Error: Alias '@bh-onee' not found. + Did you mean '@bh-one'? + """ + + Scenario: @all fails when no alias is registered + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee @all cli version' + Then the command should exit with code 1 + And STDERR should contain "Error: Cannot use '@all' when no aliases are registered." + + # Fixed in v4.13.2 (EasyEngine/easyengine#1945): remote targets were accepted but never dispatched, so the command ran on the local machine. + Scenario: A command for an ssh alias is not run locally + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + @bh-remote: + ssh: bh-nobody@127.0.0.1:1 + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee @bh-remote cli version' + Then the command should fail + And STDOUT should not contain "EE {EE_VERSION}" + And STDERR should contain "The alias '@bh-remote' has a remote target (ssh), which is not supported." + + Scenario: A group or @all with an ssh alias is refused before any member runs + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + @bh-one: + path: /var/tmp + @bh-remote: + ssh: bh-nobody@127.0.0.1:1 + @bh-group: + - @bh-one + - @bh-remote + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee @bh-group cli version' + Then the command should fail + And STDOUT should not contain "EE {EE_VERSION}" + And STDERR should contain "The alias '@bh-group' has a remote target (ssh)" + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee @all cli version' + Then the command should fail + And STDOUT should not contain "EE {EE_VERSION}" + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee @bh-one cli version' + Then the command should exit with code 0 + And STDOUT should contain "EE {EE_VERSION}" + + Scenario: --ssh and --http are refused + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee --ssh=bh-nobody@127.0.0.1:1 site list' + Then the command should fail + And STDERR should contain "The --ssh parameter is not supported." + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee site list --http=https://bh.example.test' + Then the command should fail + And STDERR should contain "The --http parameter is not supported." + + Scenario: An ssh key in the config is refused, but ee config can remove it + # The last site list passes the refusal and runs the requirement checks. + Given no EasyEngine migration is pending + And a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + ssh: bh-nobody@127.0.0.1:1 + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee site list' + Then the command should fail + And STDERR should contain "The 'ssh' config key is not supported." + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee config unset ssh' + Then the command should exit with code 0 + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee site list' + Then STDERR should not contain "config key is not supported" + + Scenario: An alias group passes the command's flags to every member + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + @bh-one: + path: /var/tmp + @bh-two: + path: /var/tmp + @bh-group: + - @bh-one + - @bh-two + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee @bh-group cli param-dump --with-values --le-mail=bh-group@example.test' + Then the command should exit with code 0 + And STDOUT should match "#^@bh-one\n\{.*"current":"bh-group@example\.test".*@bh-two\n\{.*"current":"bh-group@example\.test"#s" diff --git a/features/cli-completions.feature b/features/cli-completions.feature new file mode 100644 index 000000000..cc48c2bd7 --- /dev/null +++ b/features/cli-completions.feature @@ -0,0 +1,114 @@ +Feature: ee cli completions + + Scenario: Completing an empty line lists every top-level command + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli completions --line="ee " --point=3' + Then the command should exit with code 0 + And STDOUT should match "#^cli$#m" + And STDOUT should match "#^help$#m" + And STDOUT should match "#^site$#m" + And STDOUT should match "#^service$#m" + And STDERR should be empty + + Scenario: A partial subcommand is completed + When I run 'bin/ee cli completions --line="ee cli ve" --point=100' + Then the command should exit with code 0 + And STDOUT should be: + """ + version + """ + + Scenario: Every cli subcommand is offered after "ee cli " + When I run 'bin/ee cli completions --line="ee cli " --point=7' + Then STDOUT should be: + """ + alias + check-update + cmd-dump + completions + has-command + info + param-dump + self-uninstall + update + version + """ + + Scenario: The line is cut at the cursor position + When I run 'bin/ee cli completions --line="ee cli version" --point=9' + Then STDOUT should be: + """ + version + """ + + Scenario: zsh completions include the short description + When I run 'bin/ee cli completions --line="ee cli ve" --point=100 --shell=zsh' + Then STDOUT should be: + """ + version:Print EE version. + """ + + Scenario: Options of a leaf command are completed + When I run 'bin/ee cli completions --line="ee cli info --" --point=100' + Then STDOUT should be: + """ + --format= + """ + When I run 'bin/ee cli completions --line="ee cli check-update --ma" --point=100' + Then STDOUT should match "#^--major $#m" + + Scenario: Options already on the line are not offered again + When I run 'bin/ee cli completions --line="ee cli info --format=json --" --point=100' + Then the command should exit with code 0 + And STDOUT should be empty + + Scenario: help arguments are completed like commands + When I run 'bin/ee cli completions --line="ee help cli ve" --point=100' + Then STDOUT should be: + """ + version + """ + + Scenario: Site subcommands are completed for the default html type + When I run 'bin/ee cli completions --line="ee site cr" --point=100' + Then STDOUT should be: + """ + create + """ + + Scenario: Site options follow the --type on the line + When I run 'bin/ee cli completions --line="ee site create --type=wp --admin-u" --point=100' + Then STDOUT should match "#^--admin-user=$#m" + When I run 'bin/ee cli completions --line="ee site create --type=html --admin-u" --point=100' + Then the command should exit with code 0 + And STDOUT should be empty + + Scenario: Aliases from the config file are completed + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + @bh-one: + path: /var/tmp + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli completions --line="ee @" --point=100' + Then STDOUT should match "#^@bh-one $#m" + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli completions --line="ee @bh-one cli ve" --point=100' + Then STDOUT should be: + """ + version + """ + + Scenario: Nothing is completed for an unknown command + When I run 'bin/ee cli completions --line="ee nosuchcommand " --point=100' + Then the command should exit with code 0 + And STDOUT should be empty + + Scenario: completions rejects an unsupported shell + When I run 'bin/ee cli completions --line="ee " --point=3 --shell=fish' + Then the command should exit with code 1 + And STDERR should contain "Invalid value specified for 'shell' (Shell for completions output.)" + + Scenario: completions does not write to ee.log + When I run 'bin/ee cli completions --line="ee cli bh{RAND}" --point=100' + Then the command should exit with code 0 + And the file "{EE_ROOT_DIR}/logs/ee.log" should not contain "bh{RAND}" diff --git a/features/cli-dump.feature b/features/cli-dump.feature new file mode 100644 index 000000000..a7e5d53bb --- /dev/null +++ b/features/cli-dump.feature @@ -0,0 +1,108 @@ +Feature: ee cli param-dump and cmd-dump + + Scenario: param-dump prints the global parameter spec as json by default + When I run 'bin/ee cli param-dump' + Then the command should exit with code 0 + And STDOUT should be valid json + And the json in STDOUT should have the keys "disabled_commands, sites_path, locale, le-mail, env, wp-mail, sysctl, skip-tty, custom-compose, ee_installer_version, color, debug, quiet" + And the json in STDOUT at "debug" should have the keys "runtime, file, synopsis, default, multiple, desc" + And the json in STDOUT at "color.default" should be "auto" + And the json in STDOUT at "debug.runtime" should be "[=]" + And the json in STDOUT at "disabled_commands.runtime" should be "false" + And the json in STDOUT at "quiet.desc" should be "Suppress informational messages." + + Scenario: param-dump without --with-values has no current values + When I run 'bin/ee cli param-dump' + Then the command should exit with code 0 + And STDOUT should be valid json + And STDOUT should not contain "current" + + Scenario: param-dump --format=var_export prints a PHP array + When I run 'bin/ee cli param-dump --format=var_export' + Then the command should exit with code 0 + And STDOUT should match "#^array \(#" + And STDOUT should contain "'disabled_commands' =>" + And STDOUT should contain "'desc' => 'Whether to colorize the output.'," + + Scenario: param-dump rejects an unsupported format + When I run 'bin/ee cli param-dump --format=yaml' + Then the command should exit with code 1 + And STDERR should contain "Invalid value specified for 'format'" + + Scenario: param-dump suggests the closest flag for a typo + When I run 'bin/ee cli param-dump --with-value' + Then the command should exit with code 1 + And STDERR should contain "unknown --with-value parameter" + And STDERR should contain "Did you mean '--with-values'?" + + Scenario: param-dump --with-values shows values from the global config file + Given a file "{TMP}/config.yml" with: + """ + locale: fr_FR + ee_installer_version: stable + le-mail: global@example.test + disabled_commands: + - cli alias + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli param-dump --with-values' + Then the command should exit with code 0 + And the json in STDOUT at "le-mail.current" should be "global@example.test" + And the json in STDOUT at "locale.current" should be "fr_FR" + And the json in STDOUT at "disabled_commands.current" should be '["cli alias"]' + And the json in STDOUT at "sites_path.current" should be "null" + And the json in STDOUT at "quiet.current" should be "false" + + Scenario: A runtime flag overrides the global config value + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + le-mail: global@example.test + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli param-dump --with-values --le-mail=runtime@example.test' + Then the command should exit with code 0 + And the json in STDOUT at "le-mail.current" should be "runtime@example.test" + + Scenario: cmd-dump prints the whole command tree as json + When I run 'bin/ee cli cmd-dump' + Then the command should exit with code 0 + And STDOUT should be valid json + And the json in STDOUT at "name" should be "ee" + And the json in STDOUT at "description" should be "Manage EasyEngine through the command-line." + And the json in STDOUT at "subcommands.*.name" should contain "admin-tools" + And the json in STDOUT at "subcommands.*.name" should contain "auth" + And the json in STDOUT at "subcommands.*.name" should contain "cli" + And the json in STDOUT at "subcommands.*.name" should contain "config" + And the json in STDOUT at "subcommands.*.name" should contain "cron" + And the json in STDOUT at "subcommands.*.name" should contain "dash" + And the json in STDOUT at "subcommands.*.name" should contain "help" + And the json in STDOUT at "subcommands.*.name" should contain "log" + And the json in STDOUT at "subcommands.*.name" should contain "mailhog" + And the json in STDOUT at "subcommands.*.name" should contain "service" + And the json in STDOUT at "subcommands.*.name" should contain "shell" + And the json in STDOUT at "subcommands.*.name" should contain "site" + + Scenario: cmd-dump includes subcommands with their synopsis + When I run 'bin/ee cli cmd-dump' + Then the json in STDOUT at "subcommands.name=cli.subcommands.*.name" should contain "has-command" + And the json in STDOUT at "subcommands.name=cli.subcommands.*.name" should contain "self-uninstall" + And the json in STDOUT at "subcommands.name=cli.subcommands.name=info.synopsis" should be "[--format=]" + And the json in STDOUT at "subcommands.name=cli.subcommands.name=has-command.synopsis" should be "..." + And the json in STDOUT at "subcommands.name=cli.subcommands.name=version.description" should be "Print EE version." + And the json in STDOUT at "subcommands.name=config.subcommands.*.name" should contain "set" + + Scenario: site cmd-dump bypasses the requirement checks and dumps every site type + When I run 'bin/ee site cmd-dump' + Then the command should exit with code 0 + And STDOUT should be valid json + And STDOUT should not contain "Executing migrations" + And the json in STDOUT at "subcommands.name=site.subcommands.*.name" should contain "create --type=wp" + And the json in STDOUT at "subcommands.name=site.subcommands.*.name" should contain "create --type=php" + And the json in STDOUT at "subcommands.*.name" should not contain "site_type_wp" + + Scenario: site cmd-dump works without docker + Given the directory "{TMP}/emptybin" exists + When I run 'PATH={TMP}/emptybin {PHP} {EE_ROOT}/php/boot-fs.php site cmd-dump --debug' + Then the command should exit with code 0 + And STDOUT should be valid json + And STDERR should not contain "Docker check failed" diff --git a/features/cli-has-command.feature b/features/cli-has-command.feature new file mode 100644 index 000000000..3bc9f8d68 --- /dev/null +++ b/features/cli-has-command.feature @@ -0,0 +1,48 @@ +Feature: ee cli has-command + + Scenario Outline: Registered commands exit with 0 and print nothing + When I run 'bin/ee cli has-command ' + Then the command should exit with code 0 + And STDOUT should be empty + And STDERR should be empty + + Examples: + | command | + | cli | + | "cli version" | + | cli version | + | "site create" | + | "site delete" | + | "config get" | + | "service restart" | + | help | + + Scenario Outline: Unknown commands exit with 1 and print nothing + When I run 'bin/ee cli has-command ' + Then the command should exit with code 1 + And STDOUT should be empty + And STDERR should be empty + + Examples: + | command | + | foo | + | "foo bar" | + | "" | + + Scenario: has-command without a command prints its usage + When I run 'bin/ee cli has-command' + Then the command should exit with code 1 + And STDOUT should contain "usage: ee cli has-command ..." + + Scenario: A command disabled in the config file is reported as missing + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + disabled_commands: + - cli version + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli has-command "cli version"' + Then the command should exit with code 1 + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli has-command "cli info"' + Then the command should exit with code 0 diff --git a/features/cli-info.feature b/features/cli-info.feature new file mode 100644 index 000000000..10b92cfc7 --- /dev/null +++ b/features/cli-info.feature @@ -0,0 +1,64 @@ +# `cli info` runs the migration check when docker is available, so every scenario is guarded. +# COLUMNS keeps cli-tools from wrapping the table to the terminal width. +Feature: ee cli info + + Background: + Given no EasyEngine migration is pending + + Scenario: cli info lists every diagnostic row + When I run 'COLUMNS=250 bin/ee cli info' + Then the command should exit with code 0 + And STDOUT should not contain "Executing migrations" + And STDOUT should match "#^\| OS\s+\| \S+#m" + And STDOUT should match "#^\| Shell\s+\|#m" + And STDOUT should match "#^\| PHP binary\s+\| \S+#m" + And STDOUT should match "#^\| PHP version\s+\| \d+\.\d+#m" + And STDOUT should match "#^\| php\.ini used\s+\|#m" + And STDOUT should match "#^\| EE root dir\s+\| {EE_ROOT}\s+\|#m" + And STDOUT should match "#^\| EE vendor dir\s+\| {EE_ROOT}/vendor\s+\|#m" + And STDOUT should match "#^\| EE phar path\s+\|\s+\|#m" + And STDOUT should match "#^\| EE packages dir\s+\|#m" + And STDOUT should match "#^\| EE global config\s+\|#m" + And STDOUT should match "#^\| EE project config\s+\|#m" + And STDOUT should match "#^\| EE version\s+\| {EE_VERSION}\s+\|#m" + + Scenario: cli info --format=json returns the documented keys + When I run 'bin/ee cli info --format=json' + Then the command should exit with code 0 + And STDOUT should be valid json + And the json in STDOUT should have the keys "php_binary_path, global_config_path, project_config_path, ee_dir_path, ee_packages_dir_path, ee_version, system_os, shell" + And the json in STDOUT at "ee_version" should be "{EE_VERSION}" + And the json in STDOUT at "ee_dir_path" should be "{EE_ROOT}" + And the json in STDOUT at "project_config_path" should be "null" + + Scenario: cli info reports the global config chosen with EE_CONFIG_PATH + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli info --format=json' + Then the command should exit with code 0 + And the json in STDOUT at "global_config_path" should be "{TMP}/config.yml" + When I run 'COLUMNS=250 EE_CONFIG_PATH={TMP}/config.yml bin/ee cli info' + Then STDOUT should match "#^\| EE global config\s+\| {TMP}/config\.yml\s+\|#m" + + Scenario: cli info rejects an unsupported format + When I run 'bin/ee cli info --format=yaml' + Then the command should exit with code 1 + And STDERR should contain: + """ + Error: Parameter errors: + Invalid value specified for 'format' (Render output in a particular format.) + """ + + Scenario: cli info suggests the closest flag for a typo + When I run 'bin/ee cli info --fromat=json' + Then the command should exit with code 1 + And STDERR should contain "unknown --fromat parameter" + And STDERR should contain "Did you mean '--format'?" + + Scenario: cli info rejects positional arguments + When I run 'bin/ee cli info extra' + Then the command should exit with code 1 + And STDERR should contain "Error: Too many positional arguments: extra" diff --git a/features/cli-update.feature b/features/cli-update.feature new file mode 100644 index 000000000..d001b85af --- /dev/null +++ b/features/cli-update.feature @@ -0,0 +1,63 @@ +Feature: ee cli check-update and ee cli update + + @network + Scenario: check-update reports either the available updates or that EE is current + When I run 'bin/ee cli check-update' + Then the command should exit with code 0 + And STDOUT should match "#(^Success: EasyEngine is at the latest version\.$|^\| version\s+\| update_type\s+\| package_url\s+\|$)#m" + + @network + Scenario Outline: check-update filters by update type + When I run 'bin/ee cli check-update --' + Then the command should exit with code 0 + And STDOUT should match "#(^Success: EasyEngine is at the latest version\.$|\| \s+\|)#m" + + Examples: + | type | + | major | + | minor | + | patch | + + @network + Scenario: check-update --format=json prints nothing or a json list + When I run 'bin/ee cli check-update --format=json' + Then the command should exit with code 0 + And STDOUT should be empty or valid json + And STDOUT should not contain "Success:" + + @network + Scenario: check-update --format=count prints nothing or a number + When I run 'bin/ee cli check-update --format=count' + Then the command should exit with code 0 + And STDOUT should match "#^\s*\d*\s*$#" + + Scenario: check-update rejects an unsupported format before any request + When I run 'no_proxy= NO_PROXY= https_proxy=http://127.0.0.1:9 HTTPS_PROXY=http://127.0.0.1:9 bin/ee cli check-update --format=xml' + Then the command should exit with code 1 + And STDERR should contain "Invalid value specified for 'format'" + + Scenario: check-update fails cleanly when GitHub is unreachable + When I run 'no_proxy= NO_PROXY= https_proxy=http://127.0.0.1:9 HTTPS_PROXY=http://127.0.0.1:9 bin/ee cli check-update' + Then the command should exit with code 1 + And STDERR should contain "Error: Failed to get url 'https://api.github.com/repos/EasyEngine/easyengine/releases?per_page=100'" + + Scenario Outline: cli update refuses to run from a source checkout + When I run 'bin/ee cli update ' + Then the command should exit with code 1 + And STDERR should be: + """ + Error: You can only self-update Phar files. + """ + + Examples: + | flags | + | | + | --yes | + | --stable --yes | + | --nightly --yes | + + @network + Scenario: check-update reports the HTTP status of a failed GitHub request + When I run 'GITHUB_TOKEN=bh-invalid-token bin/ee cli check-update' + Then the command should exit with code 1 + And STDERR should contain "Error: Failed to get latest version (HTTP code 401)." diff --git a/features/cli-version.feature b/features/cli-version.feature new file mode 100644 index 000000000..602b53759 --- /dev/null +++ b/features/cli-version.feature @@ -0,0 +1,42 @@ +Feature: ee cli version + + Scenario: cli version prints the source VERSION + When I run 'bin/ee cli version' + Then the command should exit with code 0 + And STDOUT should be: + """ + EE {EE_VERSION} + """ + And STDERR should be empty + + Scenario: The global --version flag is an alias of cli version + When I run 'bin/ee --version' + Then the command should exit with code 0 + And STDOUT should be: + """ + EE {EE_VERSION} + """ + + Scenario: cli version takes no arguments, so extra ones are ignored + When I run 'bin/ee cli version extra --unknown-flag' + Then the command should exit with code 0 + And STDOUT should be: + """ + EE {EE_VERSION} + """ + + Scenario: cli version --help shows the command help instead of the version + When I run 'bin/ee cli version --help' + Then the command should exit with code 0 + And STDOUT should contain "ee cli version" + And STDOUT should contain "Print EE version." + And STDOUT should not contain "EE {EE_VERSION}" + + Scenario: cli version does not need docker or docker-compose + Given the directory "{TMP}/emptybin" exists + When I run 'PATH={TMP}/emptybin {PHP} {EE_ROOT}/php/boot-fs.php cli version' + Then the command should exit with code 0 + And STDOUT should be: + """ + EE {EE_VERSION} + """ diff --git a/features/cli.feature b/features/cli.feature index 5223ec5d2..7edc26a8c 100644 --- a/features/cli.feature +++ b/features/cli.feature @@ -1,6 +1,21 @@ +# The @uninstall scenario wipes every EasyEngine container, volume, network, image, /opt/easyengine and all +# sites. It is excluded from every profile but `uninstall`, refused without EE_BEHAT_ALLOW_UNINSTALL=1, +# and must run alone and last (later scenarios would re-initialise EasyEngine). +@destructive Feature: CLI Command + Scenario: Declining the self-uninstall prompt removes nothing + Given I record the number of containers with label "org.label-schema.vendor=EasyEngine" + # --no-yes overrides a `yes: true` default for self-uninstall in the config, which would skip the prompt. + When I run 'echo n | bin/ee cli self-uninstall --no-yes' + Then the command should exit with code 0 + And STDOUT should contain "Are you sure you want to remove EasyEngine and all its sites(along with their data)?" + And the number of containers with label "org.label-schema.vendor=EasyEngine" should be unchanged + And the file "{EE_ROOT_DIR}/db/ee.sqlite" should exist + + @uninstall Scenario: ee uninstall works properly + Given no EasyEngine migration is pending When I run 'sudo bin/ee cli info' And I run 'sudo bin/ee cli self-uninstall --yes' Then ee should be deleted diff --git a/features/config-file.feature b/features/config-file.feature new file mode 100644 index 000000000..e60243f9d --- /dev/null +++ b/features/config-file.feature @@ -0,0 +1,224 @@ +Feature: Global and project config files + + Scenario: A missing EE_CONFIG_PATH file is created with the default keys + Given the file "{TMP}/new/config.yml" does not exist + When I run 'EE_CONFIG_PATH={TMP}/new/config.yml bin/ee cli version' + Then the command should exit with code 0 + And the file "{TMP}/new/config.yml" should contain "locale: en_US" + And the file "{TMP}/new/config.yml" should contain "ee_installer_version: stable" + + Scenario: Missing default keys are added without touching existing values + Given a file "{TMP}/config.yml" with: + """ + locale: fr_FR + le-mail: behat@example.test + rclone-path: bh-remote:bucket/path + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli version' + Then the command should exit with code 0 + And the file "{TMP}/config.yml" should contain "locale: fr_FR" + And the file "{TMP}/config.yml" should contain "le-mail: behat@example.test" + And the file "{TMP}/config.yml" should contain "rclone-path: bh-remote:bucket/path" + And the file "{TMP}/config.yml" should contain "ee_installer_version: stable" + And the file "{TMP}/config.yml" should not contain "locale: en_US" + + Scenario: The config path is resolved before the defaults file is created + Given the file "{TMP}/new/config.yml" does not exist + When I run 'EE_CONFIG_PATH={TMP}/new/config.yml bin/ee cli version --debug' + Then STDERR should contain "Debug (bootstrap): No readable global config found" + When I run 'EE_CONFIG_PATH={TMP}/new/config.yml bin/ee cli version --debug' + Then STDERR should contain "Debug (bootstrap): Using global config from EE_CONFIG_PATH env var: {TMP}/new/config.yml" + + Scenario: Without a project config file the debug output says so + When I run 'cd {TMP} && EE_CONFIG_PATH={TMP}/config.yml {EE} cli version --debug' + Then STDERR should contain "Debug (bootstrap): No project config found" + + Scenario: ee.yml in the working directory is used as project config + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + le-mail: global@example.test + """ + And a file "{TMP}/project/ee.yml" with: + """ + le-mail: project@example.test + """ + When I run 'cd {TMP}/project && EE_CONFIG_PATH={TMP}/config.yml {EE} cli param-dump --with-values' + Then the command should exit with code 0 + And the json in STDOUT at "le-mail.current" should be "project@example.test" + When I run 'cd {TMP}/project && EE_CONFIG_PATH={TMP}/config.yml {EE} cli version --debug' + Then STDERR should contain "Debug (bootstrap): Using project config: {TMP}/project/ee.yml" + + Scenario: The project config is found from a subdirectory + Given a file "{TMP}/project/ee.yml" with: + """ + le-mail: project@example.test + """ + And the directory "{TMP}/project/sub/dir" exists + When I run 'cd {TMP}/project/sub/dir && EE_CONFIG_PATH={TMP}/config.yml {EE} cli param-dump --with-values' + Then the json in STDOUT at "le-mail.current" should be "project@example.test" + + Scenario: ee.local.yml takes priority over ee.yml + Given a file "{TMP}/project/ee.yml" with: + """ + le-mail: project@example.test + """ + And a file "{TMP}/project/ee.local.yml" with: + """ + le-mail: local@example.test + """ + When I run 'cd {TMP}/project && EE_CONFIG_PATH={TMP}/config.yml {EE} cli param-dump --with-values' + Then the json in STDOUT at "le-mail.current" should be "local@example.test" + + Scenario: A runtime flag overrides the project config + Given a file "{TMP}/project/ee.yml" with: + """ + le-mail: project@example.test + """ + When I run 'cd {TMP}/project && EE_CONFIG_PATH={TMP}/config.yml {EE} cli param-dump --with-values --le-mail=runtime@example.test' + Then the json in STDOUT at "le-mail.current" should be "runtime@example.test" + + Scenario: cli info reports the project config file + Given no EasyEngine migration is pending + And a file "{TMP}/project/ee.yml" with: + """ + le-mail: project@example.test + """ + When I run 'cd {TMP}/project && EE_CONFIG_PATH={TMP}/config.yml {EE} cli info --format=json' + Then the command should exit with code 0 + And the json in STDOUT at "project_config_path" should be "{TMP}/project/ee.yml" + + Scenario: A config file can inherit another one + Given a file "{TMP}/base.yml" with: + """ + le-mail: inherited@example.test + wp-mail: base@example.test + """ + And a file "{TMP}/config.yml" with: + """ + _: + inherit: {TMP}/base.yml + locale: en_US + ee_installer_version: stable + wp-mail: child@example.test + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli param-dump --with-values' + Then the json in STDOUT at "le-mail.current" should be "inherited@example.test" + And the json in STDOUT at "wp-mail.current" should be "child@example.test" + + Scenario: A disabled command cannot be run + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + disabled_commands: + - cli version + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli version' + Then the command should exit with code 1 + And STDOUT should be empty + And STDERR should be: + """ + Error: The 'cli version' command has been disabled from the config file. + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli param-dump' + Then the command should exit with code 0 + + Scenario: help for a disabled command reports that it is disabled + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + disabled_commands: + - cli version + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee help cli version' + Then the command should exit with code 1 + And STDERR should contain "Error: The 'cli version' command has been disabled from the config file." + + Scenario: A disabled subcommand is left out of the composite usage + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + disabled_commands: + - cli version + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli' + Then the command should exit with code 0 + And STDOUT should contain "ee cli update" + And STDOUT should not contain "ee cli version" + + Scenario: Disabling a command also disables its alias + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + disabled_commands: + - cli alias + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli aliases' + Then the command should exit with code 1 + And STDERR should contain "has been disabled from the config file." + + Scenario: A command section in the config file sets that command's default flags + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + cli param-dump: + format: var_export + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli param-dump' + Then the command should exit with code 0 + And STDOUT should match "#^array \(#" + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli param-dump --format=json' + Then the command should exit with code 0 + And STDOUT should be valid json + + Scenario: help leaves disabled commands out of the command lists + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + disabled_commands: + - cli version + - mailhog + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee help cli' + Then the command should exit with code 0 + And STDOUT should match "#^ update\s#m" + And STDOUT should not match "#^ version\s#m" + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee help' + Then the command should exit with code 0 + And STDOUT should match "#^ log\s#m" + And STDOUT should not match "#^ mailhog\s#m" + + Scenario: Disabling a namespace disables its subcommands + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + disabled_commands: + - cli + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli version' + Then the command should exit with code 1 + And STDERR should contain "Error: The 'cli' command has been disabled from the config file." + + Scenario: Commands from EE_PACKAGES_DIR are loaded + Given a file "{TMP}/pkgs/vendor/autoload.php" with: + """ + shows only that group, without the group label + When I run 'bin/ee cli version --debug=bootstrap' + Then STDERR should contain "Debug: Running command: cli version" + And STDERR should not contain "Debug (bootstrap)" + + Scenario: --debug with an unknown group shows nothing + When I run 'bin/ee cli version --debug=bh-no-such-group' + Then the command should exit with code 0 + And STDERR should be empty + + Scenario: debug can be enabled from the config file + Given a file "{TMP}/config.yml" with: + """ + locale: en_US + ee_installer_version: stable + debug: true + """ + When I run 'EE_CONFIG_PATH={TMP}/config.yml bin/ee cli version' + Then STDERR should contain "Debug (bootstrap): Running command: cli version" + + Scenario: In strict args mode a global flag after the command is not global + When I run 'EE_STRICT_ARGS_MODE=1 bin/ee cli version --debug' + Then the command should exit with code 0 + And STDERR should be empty + When I run 'EE_STRICT_ARGS_MODE=1 bin/ee --debug cli version' + Then STDERR should contain "Debug (bootstrap): Running command: cli version" + + Scenario: Output is not colorized when it is piped + When I run 'bin/ee cli verison' + Then STDERR should contain "Error: 'verison' is not a registered subcommand of 'cli'." + And STDERR should not contain ANSI color codes + + Scenario: SHELL_PIPE=0 makes the automatic color detection colorize + When I run 'SHELL_PIPE=0 bin/ee cli verison' + Then STDERR should contain ANSI color codes + + Scenario: Errors are still printed with --quiet + When I run 'bin/ee cli verison --quiet' + Then the command should exit with code 1 + And STDERR should contain "Error: 'verison' is not a registered subcommand of 'cli'." + + # Fixed in v4.13.2 (EasyEngine/easyengine#1944), regression check: the full argv used to be logged at debug level to ee.log, secrets included. + Scenario: Secrets passed on the command line are not written to ee.log + When I run 'bin/ee cli version --pass=bh-secret-{RAND}' + Then the command should exit with code 0 + And the file "{EE_ROOT_DIR}/logs/ee.log" should not contain "bh-secret-{RAND}" diff --git a/features/help.feature b/features/help.feature new file mode 100644 index 000000000..38fed9310 --- /dev/null +++ b/features/help.feature @@ -0,0 +1,229 @@ +Feature: ee help + + Scenario: Running ee without arguments shows the top-level help + When I run 'bin/ee' + Then the command should exit with code 0 + And STDOUT should contain: + """ + NAME + + ee + + DESCRIPTION + + Manage EasyEngine through the command-line. + + SYNOPSIS + + ee + """ + And STDOUT should contain "GLOBAL PARAMETERS" + And STDOUT should contain "--[no-]color" + And STDOUT should contain "--debug[=]" + And STDOUT should contain "--quiet" + And STDOUT should contain "Run 'ee help ' to get more information on a specific command." + And STDERR should be empty + + Scenario: ee, ee help and ee --help print the same page + When I run 'bin/ee' + And I run 'bin/ee help' + And I run 'bin/ee --help' + Then STDOUT of command 1 should be identical to STDOUT of command 2 + And STDOUT of command 1 should be identical to STDOUT of command 3 + + Scenario Outline: The top-level help lists every installed command + When I run 'bin/ee help' + Then STDOUT should match "#^ \s+\S#m" + + Examples: + | command | + | admin-tools | + | auth | + | cli | + | config | + | cron | + | dash | + | help | + | log | + | mailhog | + | service | + | shell | + | site | + + Scenario Outline: Every top-level command has a help page, also reachable with --help + When I run 'bin/ee help ' + And I run 'bin/ee --help' + Then the command should exit with code 0 + And STDOUT should contain: + """ + NAME + + ee + + """ + And STDOUT should contain: + """ + SYNOPSIS + + ee + """ + And STDOUT of command 1 should be identical to STDOUT of command 2 + + Examples: + | command | + | admin-tools | + | auth | + | cli | + | config | + | cron | + | dash | + | help | + | log | + | mailhog | + | service | + | shell | + | site | + + Scenario Outline: Every subcommand has a help page, also reachable with --help + When I run 'bin/ee help ' + And I run 'bin/ee --help' + Then the command should exit with code 0 + And STDOUT should contain: + """ + NAME + + ee + + """ + And STDOUT should contain: + """ + SYNOPSIS + + ee + """ + And STDOUT of command 1 should be identical to STDOUT of command 2 + + Examples: + | command | + | admin-tools disable | + | admin-tools enable | + | auth create | + | auth delete | + | auth list | + | auth update | + | cli alias | + | cli check-update | + | cli cmd-dump | + | cli completions | + | cli has-command | + | cli info | + | cli param-dump | + | cli self-uninstall | + | cli update | + | cli version | + | config get | + | config list | + | config set | + | config unset | + | cron create | + | cron delete | + | cron list | + | cron run-now | + | cron update | + | dash init | + | log show | + | mailhog disable | + | mailhog enable | + | mailhog status | + | service disable | + | service enable | + | service refresh | + | service reload | + | service restart | + | site backup | + | site clean | + | site clone | + | site create | + | site delete | + | site disable | + | site enable | + | site info | + | site list | + | site refresh | + | site reload | + | site restart | + | site restore | + | site share | + | site ssl-info | + | site ssl-renew | + | site ssl-verify | + | site sync | + | site update | + + Scenario Outline: site help follows the requested site type + When I run 'bin/ee help site create --type=' + Then the command should exit with code 0 + And STDOUT should contain "" + + Examples: + | type | description | + | html | Runs the standard HTML site installation. | + | wp | Runs the standard WordPress Site installation. | + | php | Runs the standard PHP Site installation. | + + Scenario: The default site type for help is html + When I run 'bin/ee help site create' + Then STDOUT should contain "Runs the standard HTML site installation." + + Scenario: WordPress-only options appear only in the wp site create help + When I run 'bin/ee help site create --type=wp' + Then STDOUT should contain "--admin-user=" + When I run 'bin/ee help site create --type=html' + Then the command should exit with code 0 + And STDOUT should contain "Runs the standard HTML site installation." + And STDOUT should not contain "--admin-user=" + + Scenario: help for an unknown site type fails + When I run 'bin/ee help site create --type=bogus' + Then the command should exit with code 1 + And STDERR should contain "Error: 'bogus' is not a registered site type" + + Scenario: help for an unknown command fails with a hint + When I run 'bin/ee help nosuchcommand' + Then the command should exit with code 1 + And STDERR should be: + """ + Error: 'nosuchcommand' is not a registered ee command. See 'ee help' for available commands. + """ + + Scenario: help for an unknown subcommand fails with a hint + When I run 'bin/ee help cli nosuchsubcommand' + Then the command should exit with code 1 + And STDERR should be: + """ + Error: 'nosuchsubcommand' is not a registered subcommand of 'cli'. See 'ee help cli' for available subcommands. + """ + + Scenario: help suggests the closest command for a typo + When I run 'bin/ee help sitee' + Then the command should exit with code 1 + And STDERR should be: + """ + Error: 'sitee' is not a registered ee command. See 'ee help' for available commands. + Did you mean 'site'? + """ + When I run 'bin/ee help cli verison' + Then STDERR should contain "Did you mean 'version'?" + + Scenario: help honours the PAGER environment variable + When I run 'PAGER="sed s/^/bh-pager:/" bin/ee help cli version' + Then the command should exit with code 0 + And STDOUT should match "#^bh-pager: ee cli version#m" + + Scenario: help pages include the command examples + When I run 'bin/ee help cli has-command' + Then STDOUT should contain "EXAMPLES" + And STDOUT should contain: + """ + $ ee cli has-command "site delete" + """ diff --git a/features/migration.feature b/features/migration.feature new file mode 100644 index 000000000..a75a73b66 --- /dev/null +++ b/features/migration.feature @@ -0,0 +1,117 @@ +# The runner compares options.version in /opt/easyengine/db/ee.sqlite with VERSION: +# - cli, config and help commands (and `site cmd-dump`) never check; `cli info` checks only when docker is up; +# - a DB version newer than VERSION: warning for `cli`, error for everything else, nothing migrated; +# - an older DB version: the full upgrade pipeline runs (db, custom container and image migrations, +# docker-compose update, cron config rewrite, logrotate) and the DB version is bumped. +# Every scenario here changes options.version, which the context restores after the scenario (also on +# Ctrl-C/SIGTERM). Only the @migration scenario ever writes an OLDER version, so a regression elsewhere +# (or a concurrent `ee` run by cron or another user) gets the "not running latest version" error instead +# of a real image/container migration. Needs EE_BEHAT_DESTRUCTIVE=1 (and EE_BEHAT_ALLOW_MIGRATION=1 for @migration). +@destructive +Feature: Migration triggering + + # The skip decision doesn't depend on the version, so a newer DB version detects a wrongly checked + # command (warning or error) without ever migrating. + Scenario Outline: Commands that skip the requirement checks never run the version check + Given no EasyEngine migration is pending + And the EasyEngine option "version" is set to "99.0.0" + When I run '' + Then STDERR should not contain "It seems you're not running latest version" + And STDOUT should not contain "Executing migrations" + And the EasyEngine option "version" should be "99.0.0" + + Examples: + | command | + | bin/ee | + | bin/ee --version | + | bin/ee cli version | + | bin/ee help | + | bin/ee help site create | + | bin/ee site create --help | + | bin/ee cli has-command site | + | bin/ee cli completions --line="ee " --point=3 | + | bin/ee cli param-dump | + | bin/ee cli cmd-dump | + | bin/ee cli alias | + | bin/ee cli update | + | bin/ee site cmd-dump | + | bin/ee config list | + + Scenario: cli info skips the version check when docker is not available + Given no EasyEngine migration is pending + And the EasyEngine option "version" is set to "99.0.0" + And the directory "{TMP}/emptybin" exists + When I run 'PATH={TMP}/emptybin {PHP} {EE_ROOT}/php/boot-fs.php cli info' + Then the command should exit with code 0 + And STDERR should not contain "It seems you're not running latest version" + And STDOUT should contain "EE version" + + Scenario: A newer DB version makes cli info warn and other commands refuse to run + Given no EasyEngine migration is pending + And the EasyEngine option "version" is set to "99.0.0" + When I run 'bin/ee cli info' + Then the command should exit with code 0 + And STDERR should contain: + """ + Warning: It seems you're not running latest version. Update EasyEngine using `ee cli update --stable --yes`. + """ + And STDOUT should contain "EE version" + When I run 'bin/ee site list' + Then the command should exit with code 1 + And STDERR should contain: + """ + Error: It seems you're not running latest version. Update EasyEngine using `ee cli update --stable --yes`. + """ + And the EasyEngine option "version" should be "99.0.0" + + Scenario: The version check runs before an unknown command is looked up + Given no EasyEngine migration is pending + And the EasyEngine option "version" is set to "99.0.0" + When I run 'bin/ee sitee' + Then the command should exit with code 1 + And STDERR should contain "Error: It seems you're not running latest version." + And STDERR should not contain "is not a registered ee command" + + # A test that tells whether the suffix is stripped would risk a real migration on failure, so this only + # checks that a newer nightly DB version is refused like a stable one. + Scenario: A newer nightly DB version also makes checked commands refuse to run + Given no EasyEngine migration is pending + And the EasyEngine option "version" is set to "99.0.0-nightly-bh" + When I run 'bin/ee site list' + Then the command should exit with code 1 + And STDERR should contain "Error: It seems you're not running latest version." + + # Runs a real migration: image pulls, cron container restart and log rotation. + # Never on a stack in use: excluded from every profile but `migration`, and refused without EE_BEHAT_ALLOW_MIGRATION=1. + @migration + Scenario: An older DB version triggers the upgrade on the next checked command + Given no EasyEngine migration is pending + And the EasyEngine option "version" is set to "4.0.0" + When I run 'bin/ee cli info' + Then the command should exit with code 0 + And STDOUT should contain "Executing migrations. This might take some time." + And the EasyEngine option "version" should be "{EE_VERSION}" + + # Registry blocked, so the image migration fails after the container migrations ran. They must be reverted + # (down() called, row removed) and run again by the next attempt; earlier ones stay recorded. + @migration + Scenario: A failed image migration reverts the container migrations of the same run + Given no EasyEngine migration is pending + And the core container migration "20990101000000_easyengine_behat_revert_probe.php" with class "BehatRevertProbe" logs its up and down calls to "{TMP}/probe.log" + And the EasyEngine option "version" is set to "4.0.0" + And the next image migration pulls at least one image + And Docker Hub is unreachable + When I run 'bin/ee cli info' + Then the command should exit with code 1 + And STDERR should contain "Error while executing ee-docker-image-migrations" + And the file "{TMP}/probe.log" should contain "up,down," + And the container migration "20990101000000_easyengine_behat_revert_probe.php" should not be recorded + And the container migration "20210830165250_auth-command_add_frontend_ip.php" should be recorded + And the EasyEngine option "version" should be "4.0.0" + When Docker Hub is reachable again + And I run 'bin/ee cli info' + Then the command should exit with code 0 + And the file "{TMP}/probe.log" should contain "up,down,up," + And the file "{TMP}/probe.log" should not contain "up,down,up,down" + And the container migration "20990101000000_easyengine_behat_revert_probe.php" should be recorded + And the EasyEngine option "version" should be "{EE_VERSION}" diff --git a/features/phar.feature b/features/phar.feature new file mode 100644 index 000000000..7ea22a2bd --- /dev/null +++ b/features/phar.feature @@ -0,0 +1,37 @@ +# Builds a phar from a private copy of the checkout into /var/tmp (~1 min, once per run) and checks the phar bootstrap. +# `cli update` is not exercised: from a phar it would really download and swap the phar. +# EE_DISABLE_AUTO_CHECK_UPDATE keeps `help` from ever offering a self-update of the built phar. +@phar +Feature: Phar build + + Background: + Given ee phar is generated + + Scenario: The phar reports the source VERSION + When I run '{PHP} {PHAR} cli version' + Then the command should exit with code 0 + And STDOUT should be: + """ + EE {EE_VERSION} + """ + + Scenario: The phar serves help pages + When I run 'EE_DISABLE_AUTO_CHECK_UPDATE=1 {PHP} {PHAR} help cli' + Then the command should exit with code 0 + And STDOUT should contain "ee cli" + And STDOUT should contain "self-uninstall" + + Scenario: The phar registers the bundled command packages + When I run '{PHP} {PHAR} cli has-command "site create"' + Then the command should exit with code 0 + When I run '{PHP} {PHAR} cli has-command "service restart"' + Then the command should exit with code 0 + When I run '{PHP} {PHAR} cli has-command "admin-tools enable"' + Then the command should exit with code 0 + + Scenario: The phar cli info reports the phar as EE root + Given no EasyEngine migration is pending + When I run '{PHP} {PHAR} cli info --format=json' + Then the command should exit with code 0 + And the json in STDOUT at "ee_dir_path" should be "phar://ee.phar" + And the json in STDOUT at "ee_version" should be "{EE_VERSION}" diff --git a/features/requirements.feature b/features/requirements.feature new file mode 100644 index 000000000..290c9409f --- /dev/null +++ b/features/requirements.feature @@ -0,0 +1,38 @@ +# The runner only checks requirements (docker, docker-compose) for commands other than cli/config/help. +# These scenarios hide the binaries with a private PATH, so they never reach a migration. +Feature: Requirement checks + + Scenario: help works without docker or docker-compose + # The default pager (`less -r`) is looked up in PATH too, so point PAGER at an absolute cat. + Given the directory "{TMP}/emptybin" exists + When I run 'PAGER=/bin/cat PATH={TMP}/emptybin {PHP} {EE_ROOT}/php/boot-fs.php help cli' + Then the command should exit with code 0 + And STDOUT should contain "ee cli" + + Scenario: cli info still works when docker is not available + # Requirements are only probed (4 attempts, ~7s of backoff), so no migration can run. + Given the directory "{TMP}/emptybin" exists + When I run 'PATH={TMP}/emptybin {PHP} {EE_ROOT}/php/boot-fs.php cli info --format=json' + Then the command should exit with code 0 + And the json in STDOUT at "ee_version" should be "{EE_VERSION}" + + Scenario: A checked command reports a missing docker-compose + Given the executable "docker" is linked into "{TMP}/bin" + When I run 'PATH={TMP}/bin {PHP} {EE_ROOT}/php/boot-fs.php site list' + Then STDERR should contain "Warning: EasyEngine requires docker-compose version 2.26.1 or greater." + And STDERR should contain "Error: EasyEngine requires docker-compose." + And STDOUT should not contain "Executing migrations" + + Scenario: A checked command retries the docker probe before giving up + Given the directory "{TMP}/emptybin" exists + When I run 'PATH={TMP}/emptybin {PHP} {EE_ROOT}/php/boot-fs.php site list --debug' + Then STDERR should contain "Docker check failed (attempt 1/4), retrying in 1s..." + And STDERR should contain "Docker check failed (attempt 3/4), retrying in 4s..." + And STDERR should contain "Error: Docker not installed or not running (checked 4 times)." + + # Safe by construction: without docker the command stops before the prompt, which `n` would decline anyway. + Scenario: self-uninstall refuses to run when docker is not available + Given the directory "{TMP}/emptybin" exists + When I run 'echo n | PATH={TMP}/emptybin {PHP} {EE_ROOT}/php/boot-fs.php cli self-uninstall' + Then STDERR should contain "Error: Unable to proceed with uninstallation. Seems there is a dependency down." + And STDOUT should not contain "Are you sure" diff --git a/features/unknown-command.feature b/features/unknown-command.feature new file mode 100644 index 000000000..a2ea459a3 --- /dev/null +++ b/features/unknown-command.feature @@ -0,0 +1,71 @@ +Feature: Unknown commands and composite commands + + Scenario: An unknown cli subcommand suggests the closest one + When I run 'bin/ee cli verison' + Then the command should exit with code 1 + And STDOUT should be empty + And STDERR should be: + """ + Error: 'verison' is not a registered subcommand of 'cli'. See 'ee help cli' for available subcommands. + Did you mean 'version'? + """ + + Scenario Outline: Common shorthands map to the real subcommand + When I run 'bin/ee cli ' + Then the command should exit with code 1 + And STDERR should contain "Did you mean ''?" + + Examples: + | typed | suggestion | + | v | version | + | check | check-update | + | cmd-dmp | cmd-dump | + | param-dum | param-dump | + | alais | alias | + + Scenario: No suggestion is offered when nothing is close + When I run 'bin/ee cli zzzzzzzzzz' + Then the command should exit with code 1 + And STDERR should contain "Error: 'zzzzzzzzzz' is not a registered subcommand of 'cli'." + And STDERR should not contain "Did you mean" + + # Unknown top-level commands pass the requirement and migration checks before the lookup. + Scenario: An unknown top-level command suggests the closest one + Given no EasyEngine migration is pending + When I run 'bin/ee sitee' + Then the command should exit with code 1 + And STDERR should contain: + """ + Error: 'sitee' is not a registered ee command. See 'ee help' for available commands. + Did you mean 'site'? + """ + + Scenario: A composite command without a subcommand prints its usage + When I run 'bin/ee cli' + Then the command should exit with code 0 + And STDOUT should contain "usage: ee cli alias [--format=]" + And STDOUT should contain " or: ee cli version" + And STDOUT should contain "See 'ee help cli ' for more information on a specific command." + + Scenario Outline: Every cli subcommand appears in the cli usage + When I run 'bin/ee cli' + Then STDOUT should match "#^(usage| or): ee cli ( |$)#m" + + Examples: + | subcommand | + | alias | + | check-update | + | cmd-dump | + | completions | + | has-command | + | info | + | param-dump | + | self-uninstall | + | update | + | version | + + Scenario: A subcommand called without its required parameters fails + When I run 'bin/ee cli completions' + Then the command should exit with code 1 + And STDERR should contain "missing --line parameter" + And STDERR should contain "missing --point parameter"