From 5a1522114a234e0f62856763e50ac32841741d82 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 11:32:25 +0530 Subject: [PATCH 01/22] test(log-command): add behat context with shared steps Based on site-command's context. Cleanup only deletes sites with the bh-log-command- prefix, the unanchored I run and STDOUT regexes are anchored, and a marked section adds generic steps: lazy site setup, file fixtures confined to the suite's sites, commands bounded by timeout(1) for tail -f, stream and exit-code assertions, and HTTP checks. --- features/bootstrap/FeatureContext.php | 858 ++++++++++++++++++++++++++ features/bootstrap/support.php | 207 +++++++ 2 files changed, 1065 insertions(+) create mode 100644 features/bootstrap/FeatureContext.php create mode 100644 features/bootstrap/support.php diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php new file mode 100644 index 0000000..601cbfe --- /dev/null +++ b/features/bootstrap/FeatureContext.php @@ -0,0 +1,858 @@ +init_logger(); +$runner->init_config(); +/* End. Loading required files to enable EE::launch() in tests. */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\AfterFeatureScope; +use Behat\Behat\Hook\Scope\AfterScenarioScope; + +use Behat\Gherkin\Node\PyStringNode, + Behat\Gherkin\Node\TableNode; + +define( 'EE_SITE_ROOT', EE_ROOT_DIR . '/sites' ); + +class FeatureContext implements Context +{ + public $command; + public $commands; + public $webroot_path; + public $ee_path; + + /** + * Only sites starting with this prefix are ever created or deleted by this suite. + */ + const SITE_PREFIX = 'bh-log-command-'; + + /** + * Initializes context. + */ + public function __construct() + { + $this->commands = []; + $this->ee_path = getcwd(); + } + + /** + * @Given ee phar is generated + */ + public function eePharIsPresent() + { + // Checks if phar already exists, replaces it + if(file_exists('ee-old.phar')) { + // Below exec call is required as currenly `ee cli update` is ran with root + // which updates ee.phar with root privileges. + exec("sudo rm ee.phar"); + copy('ee-old.phar','ee.phar'); + return 0; + } + exec("php -dphar.readonly=0 utils/make-phar.php ee.phar", $output, $return_status); + if (0 !== $return_status) { + throw new Exception("Unable to generate phar" . $return_status); + } + + // Cache generaed phar as it is expensive to generate one + copy('ee.phar','ee-old.phar'); + } + + /** + * @Given :command is installed + */ + public function isInstalled($command) + { + exec("type " . $command, $output, $return_status); + if (0 !== $return_status) { + throw new Exception($command . " is not installed! Exit code is:" . $return_status); + } + } + + /** + * @When /^I run ['"](.*)['"]$/ + */ + public function iRun($command) + { + $this->commands[] = EE::launch($command, false, true); + } + + /** + * @When I create subsite :subsite in :site + */ + public function iCreateSubsiteInOfType($subsite, $site ) + { + $php_container = implode( explode( '.', $subsite ) ) . '_php_1'; + + EE::launch( "docker exec -it --user='www-data' $php_container sh -c 'wp site create --slug=$site'", false, true ); + } + + /** + * @Then return value should be 0 + */ + public function returnValueShouldBe0() + { + if ( 0 !== $this->commands[0]->return_code ) { + throw new Exception("Actual return code is not zero: \n" . $this->command); + } + } + + /** + * @Then return value of command :index should be 0 + */ + public function returnValueOfCommandShouldBe0($index) + { + if ( 0 !== $this->commands[ $index - 1 ]->return_code ) { + throw new Exception("Actual return code is not zero: \n" . $this->command); + } + } + + /** + * @Then After delay of :time seconds + */ + public function afterDelayOfSeconds( $time ) + { + sleep( $time ); + } + + /** + * @Then /^(STDOUT|STDERR) should return exactly$/ + */ + public function stdoutShouldReturnExactly($output_stream, PyStringNode $expected_output) + { + $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; + + $command_output = str_replace(["\033[1;31m","\033[0m"],'',$command_output); + + if ( $expected_output->getRaw() !== trim($command_output)) { + throw new Exception("Actual output is:\n" . $command_output); + } + } + + /** + * @Then /^(STDOUT|STDERR) of command (\d+) should return exactly$/ + */ + public function stdoutOfCommandShouldReturnExactly($output_stream, $index, PyStringNode $expected_output) + { + $command_output = $output_stream === "STDOUT" ? $this->commands[ $index - 1 ]->stdout : $this->commands[ $index - 1 ]->stderr; + + $command_output = str_replace(["\033[1;31m","\033[0m"],'',$command_output); + + $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; + if ( $expected_out !== trim($command_output)) { + throw new Exception("Actual output is:\n" . $command_output); + } + } + + /** + * @Then /^(STDOUT|STDERR) should return something like$/ + */ + public function stdoutShouldReturnSomethingLike($output_stream, PyStringNode $expected_output) + { + $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; + + $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; + 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] : ''; + 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 --user='www-data' php sh -c 'wp config get SUBDOMAIN_INSTALL'", false, true ); + + if( $result->stderr ) { + throw new Exception("Found error while executing command: $result->stderr"); + } + + if($type === 'subdomain' && trim($result->stdout) !== '1') { + throw new Exception("Expecting SUBDOMAIN_INSTALL to be 1. Got: $result->stdout"); + } + else if($type === 'subdir' && trim($result->stdout) !== '') { + throw new Exception("Expecting SUBDOMAIN_INSTALL to be empty. Got: $result->stdout"); + } + else if($type !== 'subdomain' && $type !== 'subdir') { + throw new Exception("Found unknown site type: $type"); + } + } + + /** + * @Then The :site db entry should be removed + */ + public function theDbEntryShouldBeRemoved($site) + { + $out = shell_exec("sudo bin/ee site list"); + if (strpos($out, $site) !== false) { + throw new Exception("$site db entry not been removed!"); + } + + } + + /** + * @Then The :site webroot should be removed + */ + public function theWebrootShouldBeRemoved($site) + { + if ( file_exists( EE_SITE_ROOT . '/' . $site ) ) { + throw new Exception("Webroot has not been removed!"); + } + } + + /** + * @Then Following containers of site :site should be removed: + */ + public function followingContainersOfSiteShouldBeRemoved($site, TableNode $table) + { + $containers = $table->getHash(); + $site_name = implode(explode('.', $site)); + + foreach ($containers as $container) { + + $sevice = $container['container']; + $container_name = $site_name . '_' . $sevice . '_1'; + + exec("docker inspect -f '{{.State.Running}}' $container_name > /dev/null 2>&1", $exec_out, $return); + if (!$return) { + throw new Exception("$container_name has not been removed!"); + } + } + } + + /** + * @Then The site :site should have webroot + */ + public function theSiteShouldHaveWebroot($site) + { + if ( ! file_exists( EE_SITE_ROOT . '/' . $site ) ) { + throw new Exception("Webroot has not been created!"); + } + } + + /** + * @Then The site :site should have WordPress + */ + public function theSiteShouldHaveWordpress($site) + { + if ( ! file_exists( EE_SITE_ROOT . '/' . $site . '/app/wp-config.php' ) ) { + throw new Exception("WordPress data not found!"); + } + } + + /** + * @Then Request on :site should contain following headers: + */ + public function requestOnShouldContainFollowingHeaders($site, TableNode $table) + { + $url = 'http://' . $site; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_NOBODY, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_VERBOSE, true); + $headers = curl_exec($ch); + + curl_close($ch); + + $rows = $table->getHash(); + + foreach ($rows as $row) { + if (strpos($headers, $row['header']) === false) { + throw new Exception("Unable to find " . $row['header'] . "\nActual output is : " . $headers); + } + } + } + + /** + * @Then Request on :host with header :header should contain following headers: + */ + public function requestOnWithHeaderShouldContainFollowingHeaders($host, $header, TableNode $table) + { + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $host); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ $header ]); + curl_setopt($ch, CURLOPT_NOBODY, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_VERBOSE, true); + $headers = curl_exec($ch); + + curl_close($ch); + + $rows = $table->getHash(); + + foreach ($rows as $row) { + if (strpos($headers, $row['header']) === false) { + throw new Exception("Unable to find " . $row['header'] . "\nActual output is : " . $headers); + } + } + } + + /** + * @Then Request on :host with resolve option :resolve should contain following headers: + */ + public function requestOnWithResolveOptionShouldContainFollowingHeaders($host, $resolve, TableNode $table) + { + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $host); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_NOBODY, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_RESOLVE, [ $resolve ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_VERBOSE, true); + $headers = curl_exec($ch); + curl_close($ch); + + $rows = $table->getHash(); + + foreach ($rows as $row) { + if (strpos($headers, $row['header']) === false) { + throw new Exception("Unable to find " . $row['header'] . "\nActual output is : " . $headers); + } + } + } + + /** + * @AfterScenario + */ + public function cleanupScenario(AfterScenarioScope $scope) + { + $this->commands = []; + chdir($this->ee_path); + } + + /** + * @Then There should be :expected_running_containers containers with labels + */ + public function thereShouldBeContainersWithLabel($expected_running_containers, PyStringNode $pyStringNode) + { + $labels = $pyStringNode->getStrings(); + $label_string = implode(' -f label=',$labels); + + $result = EE::launch( "docker ps -qf label=$label_string | wc -l", false, true ); + $running_containers = (int) trim($result->stdout); + + if($expected_running_containers === $running_containers) { + throw new Exception("Expected $expected_running_containers running containers. Found: $running_containers"); + } + } + + /** + * Deletes only this suite's sites (never every site) and the cached phars. + * + * @AfterFeature + */ + public static function cleanup( AfterFeatureScope $scope ) + { + foreach ( self::sitesWithPrefix( self::SITE_PREFIX ) as $site ) { + EE::launch( 'bin/ee site delete ' . escapeshellarg( $site ) . ' --yes', false, true ); + } + + foreach ( [ 'ee.phar', 'ee-old.phar' ] as $phar ) { + if ( file_exists( $phar ) ) { + unlink( $phar ); + } + } + } + + /* + * --------------------------------------------------------------------- + * Shared generic steps (added for log-command; generic enough to share). + * + * Paths and expected texts accept the placeholders {EE_ROOT_DIR} + * (/opt/easyengine), {SITES_DIR} ({EE_ROOT_DIR}/sites) and {EE_ROOT} + * (the core checkout running the suite). + * --------------------------------------------------------------------- + */ + + /** + * Files and directories created by steps, removed after each scenario (newest first). + * + * @var array + */ + private $created_paths = []; + + /** + * Replaces the path placeholders in a step argument. + */ + private function expand( $text ) + { + return strtr( + $text, + [ + '{EE_ROOT_DIR}' => EE_ROOT_DIR, + '{SITES_DIR}' => EE_SITE_ROOT, + '{EE_ROOT}' => $this->ee_path, + ] + ); + } + + /** + * Runs a query against EasyEngine's sqlite DB (read-only use). + */ + private static function dbQuery( $sql, array $params = [] ) + { + $db_file = EE_ROOT_DIR . '/db/ee.sqlite'; + if ( ! file_exists( $db_file ) ) { + return []; + } + $db = new PDO( 'sqlite:' . $db_file ); + $db->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + $stmt = $db->prepare( $sql ); + $stmt->execute( $params ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + private static function sitesWithPrefix( $prefix ) + { + $sites = array_column( self::dbQuery( 'SELECT site_url FROM sites' ), 'site_url' ); + + return array_values( + array_filter( + $sites, + function ( $site ) use ( $prefix ) { + return 0 === strpos( $site, $prefix ); + } + ) + ); + } + + private static function siteRow( $site ) + { + $rows = self::dbQuery( 'SELECT site_url, site_type, site_enabled, site_fs_path FROM sites WHERE site_url = ?', [ $site ] ); + + return empty( $rows ) ? null : $rows[0]; + } + + private static function assertOwnSite( $site ) + { + if ( 0 !== strpos( $site, self::SITE_PREFIX ) ) { + throw new Exception( "Refusing to manage '$site': this suite only touches sites starting with '" . self::SITE_PREFIX . "'." ); + } + } + + /** + * Write steps may only touch files inside this suite's own site directories. + */ + private static function assertOwnPath( $path ) + { + $own_root = EE_SITE_ROOT . '/' . self::SITE_PREFIX; + if ( 0 !== strpos( $path, $own_root ) || false !== strpos( $path, '..' ) ) { + throw new Exception( "Refusing to modify '$path': only paths under '$own_root*' are allowed." ); + } + } + + private static function stripAnsi( $text ) + { + return preg_replace( '/\e\[[0-9;]*m/', '', $text ); + } + + private static function describe( $result ) + { + return sprintf( + "Command: %s\nReturn code: %s\n--- STDOUT ---\n%s\n--- STDERR ---\n%s", + $result->command, + $result->return_code, + self::stripAnsi( $result->stdout ), + self::stripAnsi( $result->stderr ) + ); + } + + private function lastCommand() + { + if ( empty( $this->commands ) ) { + throw new Exception( 'No command has been run in this scenario.' ); + } + + return end( $this->commands ); + } + + private function streamOf( $stream ) + { + $result = $this->lastCommand(); + + return self::stripAnsi( 'STDOUT' === $stream ? $result->stdout : $result->stderr ); + } + + /** + * Mirrors mkdir -p and remembers what it created. + */ + private function makeDir( $dir ) + { + $missing = []; + while ( ! is_dir( $dir ) ) { + $missing[] = $dir; + $dir = dirname( $dir ); + } + foreach ( array_reverse( $missing ) as $new_dir ) { + if ( ! mkdir( $new_dir ) ) { + throw new Exception( "Could not create directory $new_dir" ); + } + $this->created_paths[] = $new_dir; + } + } + + /** + * Runs a command in its own process group under timeout(1), so long-running followers + * (e.g. `tail -f`) are killed together with ee. Exit code 124 means the timeout expired. + */ + private function launchWithTimeout( $command, $seconds, $cwd = null, $before = '' ) + { + $timed = sprintf( + 'timeout -k 5 %d env BEHAT_RUN=1 sh -c %s', + (int) $seconds, + escapeshellarg( $this->expand( $command ) ) + ); + if ( '' !== $before ) { + $timed = "$before $timed; rc=\$?; wait; exit \$rc"; + } + + return EE::launch( $timed, false, true, [], null, $cwd ); + } + + /** + * @Given the site :site of type :type exists + */ + public function theSiteOfTypeExists( $site, $type ) + { + self::assertOwnSite( $site ); + $row = self::siteRow( $site ); + + if ( $row && $row['site_type'] !== $type ) { + throw new Exception( "Site $site already exists with type {$row['site_type']}, expected $type." ); + } + + if ( ! $row ) { + $result = EE::launch( 'bin/ee site create ' . escapeshellarg( $site ) . ' --type=' . escapeshellarg( $type ), false, true ); + $row = self::siteRow( $site ); + if ( 0 !== $result->return_code || ! $row ) { + throw new Exception( "Could not create site $site.\n" . self::describe( $result ) ); + } + } + + if ( ! $row['site_enabled'] ) { + $result = EE::launch( 'bin/ee site enable ' . escapeshellarg( $site ), false, true ); + $row = self::siteRow( $site ); + if ( ! $row['site_enabled'] ) { + throw new Exception( "Could not enable site $site.\n" . self::describe( $result ) ); + } + } + } + + /** + * @Given the site :site is disabled + */ + public function theSiteIsDisabled( $site ) + { + self::assertOwnSite( $site ); + $row = self::siteRow( $site ); + if ( ! $row ) { + throw new Exception( "Site $site does not exist." ); + } + + if ( $row['site_enabled'] ) { + $result = EE::launch( 'bin/ee site disable ' . escapeshellarg( $site ), false, true ); + $row = self::siteRow( $site ); + if ( $row['site_enabled'] ) { + throw new Exception( "Could not disable site $site.\n" . self::describe( $result ) ); + } + } + } + + /** + * @Then the site :site should be :state + */ + public function theSiteShouldBe( $site, $state ) + { + $row = self::siteRow( $site ); + $actual = ! $row ? 'missing' : ( $row['site_enabled'] ? 'enabled' : 'disabled' ); + if ( $actual !== $state ) { + throw new Exception( "Expected site $site to be $state, it is $actual." ); + } + } + + /** + * @Given the site :site does not exist + */ + public function theSiteDoesNotExist( $site ) + { + self::assertOwnSite( $site ); + if ( self::siteRow( $site ) ) { + $result = EE::launch( 'bin/ee site delete ' . escapeshellarg( $site ) . ' --yes', false, true ); + if ( self::siteRow( $site ) ) { + throw new Exception( "Could not delete site $site.\n" . self::describe( $result ) ); + } + } + } + + /** + * @Given the file :path contains: + */ + public function theFileContains( $path, PyStringNode $content ) + { + $path = $this->expand( $path ); + self::assertOwnPath( $path ); + $this->makeDir( dirname( $path ) ); + if ( ! file_exists( $path ) ) { + $this->created_paths[] = $path; + } + if ( false === file_put_contents( $path, $this->expand( $content->getRaw() ) . "\n" ) ) { + throw new Exception( "Could not write $path" ); + } + } + + /** + * @Given the file :path does not exist + */ + public function theFileDoesNotExist( $path ) + { + $path = $this->expand( $path ); + self::assertOwnPath( $path ); + if ( file_exists( $path ) && ! unlink( $path ) ) { + throw new Exception( "Could not delete $path" ); + } + } + + /** + * @Then the file :path should exist + */ + public function theFileShouldExist( $path ) + { + $path = $this->expand( $path ); + if ( ! file_exists( $path ) ) { + throw new Exception( "Expected file $path to exist." ); + } + } + + /** + * @Then the file :path should contain :text + */ + public function theFileShouldContain( $path, $text ) + { + $path = $this->expand( $path ); + $text = $this->expand( $text ); + if ( ! file_exists( $path ) ) { + throw new Exception( "Expected file $path to exist." ); + } + $content = file_get_contents( $path ); + if ( false === strpos( $content, $text ) ) { + throw new Exception( "Expected $path to contain '$text'. Last 2 KB:\n" . substr( $content, -2048 ) ); + } + } + + /** + * @When I run :command with a timeout of :seconds seconds + */ + public function iRunWithATimeoutOfSeconds( $command, $seconds ) + { + $this->commands[] = $this->launchWithTimeout( $command, $seconds ); + } + + /** + * Runs from another working directory; a leading `bin/ee` still means this checkout's bin/ee. + * + * @When I run :command from :dir with a timeout of :seconds seconds + */ + public function iRunFromWithATimeoutOfSeconds( $command, $dir, $seconds ) + { + $dir = $this->expand( $dir ); + if ( ! is_dir( $dir ) ) { + throw new Exception( "Directory $dir does not exist." ); + } + $command = preg_replace( '#(^|\s)bin/ee(?=\s|$)#', '$1' . $this->ee_path . '/bin/ee', $command ); + + $this->commands[] = $this->launchWithTimeout( $command, $seconds, $dir ); + } + + /** + * Appends :text to :path once a `tail -f` on that file is running, to prove live following. + * + * @When I run :command with a timeout of :seconds seconds while :text is appended to the file :path once tail follows it + */ + public function iRunWhileTextIsAppendedOnceTailFollows( $command, $seconds, $text, $path ) + { + $path = $this->expand( $path ); + self::assertOwnPath( $path ); + if ( ! file_exists( $path ) ) { + throw new Exception( "File $path must exist before it can be followed." ); + } + + // Anchored so only the tail process itself matches, never a wrapper shell's command line. + $pattern = '^tail .*-f .*' . basename( $path ); + $writer = sprintf( + '( i=0; while [ $i -lt 150 ] && ! pgrep -f -- %1$s >/dev/null; do sleep 0.1; i=$((i+1)); done; sleep 1; printf "%%s\n" %2$s >> %3$s ) >/dev/null 2>&1 &', + escapeshellarg( $pattern ), + escapeshellarg( $this->expand( $text ) ), + escapeshellarg( $path ) + ); + + $this->commands[] = $this->launchWithTimeout( $command, $seconds, null, $writer ); + } + + /** + * @Then the command should still be running when the timeout expires + */ + public function theCommandShouldStillBeRunningWhenTheTimeoutExpires() + { + $result = $this->lastCommand(); + if ( 124 !== $result->return_code ) { + throw new Exception( "Expected the command to still be running at the timeout (exit 124).\n" . self::describe( $result ) ); + } + } + + /** + * @Then the command should exit with code :code + */ + public function theCommandShouldExitWithCode( $code ) + { + $result = $this->lastCommand(); + if ( (int) $code !== $result->return_code ) { + throw new Exception( "Expected exit code $code.\n" . self::describe( $result ) ); + } + } + + /** + * @Then the command should exit with a non-zero code + */ + public function theCommandShouldExitWithANonZeroCode() + { + $result = $this->lastCommand(); + if ( 0 === $result->return_code || 124 === $result->return_code ) { + throw new Exception( "Expected the command to fail on its own (non-zero exit, not a timeout).\n" . self::describe( $result ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should (not )?contain "(.*)"$/ + */ + public function streamShouldContain( $stream, $negate, $text ) + { + $text = $this->expand( $text ); + $found = false !== strpos( $this->streamOf( $stream ), $text ); + $expected = '' === $negate; + if ( $found !== $expected ) { + throw new Exception( sprintf( "Expected %s %sto contain '%s'.\n%s", $stream, $negate, $text, self::describe( $this->lastCommand() ) ) ); + } + } + + /** + * @Then /^(STDOUT|STDERR) should contain "(.*)" exactly (\d+) times?$/ + */ + public function streamShouldContainTimes( $stream, $text, $times ) + { + $text = $this->expand( $text ); + $count = substr_count( $this->streamOf( $stream ), $text ); + if ( (int) $times !== $count ) { + throw new Exception( sprintf( "Expected %s to contain '%s' %d time(s), found %d.\n%s", $stream, $text, $times, $count, self::describe( $this->lastCommand() ) ) ); + } + } + + /** + * @Then a GET request to :url with Host :host should return :code + */ + public function aGetRequestWithHostShouldReturn( $url, $host, $code ) + { + $ch = curl_init(); + curl_setopt( $ch, CURLOPT_URL, $this->expand( $url ) ); + curl_setopt( $ch, CURLOPT_HTTPHEADER, [ 'Host: ' . $host ] ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, false ); + curl_setopt( $ch, CURLOPT_TIMEOUT, 30 ); + $body = curl_exec( $ch ); + $actual = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $error = curl_error( $ch ); + curl_close( $ch ); + + if ( (int) $code !== $actual ) { + throw new Exception( "Expected HTTP $code from $url (Host: $host), got $actual. $error\n" . substr( (string) $body, 0, 1024 ) ); + } + } + + /** + * @AfterScenario + */ + public function removeCreatedPaths( AfterScenarioScope $scope ) + { + foreach ( array_reverse( $this->created_paths ) as $path ) { + if ( is_dir( $path ) && ! is_link( $path ) ) { + @rmdir( $path ); + } elseif ( file_exists( $path ) || is_link( $path ) ) { + @unlink( $path ); + } + } + $this->created_paths = []; + } +} diff --git a/features/bootstrap/support.php b/features/bootstrap/support.php new file mode 100644 index 0000000..ed9a5b5 --- /dev/null +++ b/features/bootstrap/support.php @@ -0,0 +1,207 @@ + $value ) { + if ( ! compareContents( $value, $actual->$name ) ) { + return false; + } + } + } else if ( is_array( $expected ) ) { + foreach ( $expected as $key => $value ) { + if ( ! compareContents( $value, $actual[$key] ) ) { + return false; + } + } + } else { + return $expected === $actual; + } + + return true; +} + +/** + * Compare two strings containing JSON to ensure that @a $actualJson contains at + * least what the JSON string @a $expectedJson contains. + * + * @return whether or not @a $actualJson contains @a $expectedJson + * @retval true @a $actualJson contains @a $expectedJson + * @retval false @a $actualJson does not contain @a $expectedJson + * + * @param [in] $actualJson the JSON string to be tested + * @param [in] $expectedJson the expected JSON string + * + * Examples: + * expected: {'a':1,'array':[1,3,5]} + * + * 1 ) + * actual: {'a':1,'b':2,'c':3,'array':[1,2,3,4,5]} + * return: true + * + * 2 ) + * actual: {'b':2,'c':3,'array':[1,2,3,4,5]} + * return: false + * element 'a' is missing from the root object + * + * 3 ) + * actual: {'a':0,'b':2,'c':3,'array':[1,2,3,4,5]} + * return: false + * the value of element 'a' is not 1 + * + * 4 ) + * actual: {'a':1,'b':2,'c':3,'array':[1,2,4,5]} + * return: false + * the contents of 'array' does not include 3 + */ +function checkThatJsonStringContainsJsonString( $actualJson, $expectedJson ) { + $actualValue = json_decode( $actualJson ); + $expectedValue = json_decode( $expectedJson ); + + if ( ! $actualValue ) { + return false; + } + + return compareContents( $expectedValue, $actualValue ); +} + +/** + * Compare two strings to confirm $actualCSV contains $expectedCSV + * Both strings are expected to have headers for their CSVs. + * $actualCSV must match all data rows in $expectedCSV + * + * @param string A CSV string + * @param array A nested array of values + * + * @return bool Whether $actualCSV contains $expectedCSV + */ +function checkThatCsvStringContainsValues( $actualCSV, $expectedCSV ) { + $actualCSV = array_map( 'str_getcsv', explode( PHP_EOL, $actualCSV ) ); + + if ( empty( $actualCSV ) ) { + return false; + } + + // Each sample must have headers + $actualHeaders = array_values( array_shift( $actualCSV ) ); + $expectedHeaders = array_values( array_shift( $expectedCSV ) ); + + // Each expectedCSV must exist somewhere in actualCSV in the proper column + $expectedResult = 0; + foreach ( $expectedCSV as $expected_row ) { + $expected_row = array_combine( $expectedHeaders, $expected_row ); + foreach ( $actualCSV as $actual_row ) { + + if ( count( $actualHeaders ) != count( $actual_row ) ) { + continue; + } + + $actual_row = array_intersect_key( array_combine( $actualHeaders, $actual_row ), $expected_row ); + if ( $actual_row == $expected_row ) { + $expectedResult ++; + } + } + } + + return $expectedResult >= count( $expectedCSV ); +} + +/** + * Compare two strings containing YAML to ensure that @a $actualYaml contains at + * least what the YAML string @a $expectedYaml contains. + * + * @return whether or not @a $actualYaml contains @a $expectedJson + * @retval true @a $actualYaml contains @a $expectedJson + * @retval false @a $actualYaml does not contain @a $expectedJson + * + * @param [in] $actualYaml the YAML string to be tested + * @param [in] $expectedYaml the expected YAML string + */ +function checkThatYamlStringContainsYamlString( $actualYaml, $expectedYaml ) { + $actualValue = Mustangostang\Spyc::YAMLLoad( $actualYaml ); + $expectedValue = Mustangostang\Spyc::YAMLLoad( $expectedYaml ); + + if ( ! $actualValue ) { + return false; + } + + return compareContents( $expectedValue, $actualValue ); +} + From 21a213950f41e2568d767bb3d6952752fcbf8087 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 11:37:38 +0530 Subject: [PATCH 02/22] test(log-command): cover command surface and site resolution --- features/log.feature | 92 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 features/log.feature diff --git a/features/log.feature b/features/log.feature new file mode 100644 index 0000000..cb0159e --- /dev/null +++ b/features/log.feature @@ -0,0 +1,92 @@ +Feature: ee log command surface, site resolution and validation errors + + # `ee log show` ends in `tail -f`, so every invocation that can reach tail runs under a timeout; + # exit code 124 ("still running when the timeout expired") is the success signal for following. + + Scenario: ee log without a subcommand lists show + When I run 'bin/ee log' + Then STDOUT should contain "usage: ee log show" + And the command should exit with code 0 + + Scenario: ee help log show documents every option + When I run 'bin/ee help log show' + Then the command should exit with code 0 + And STDOUT should contain "ee log show [] [--n=] [--global] [--cli] [--all]" + And STDOUT should contain "[--nginx] [--php] [--wp] [--access] [--error]" + And STDOUT should contain "Monitor site or global logs." + + Scenario: log tail is not a registered subcommand + When I run 'bin/ee log tail' + Then STDERR should contain "Error: 'tail' is not a registered subcommand of 'log'. See 'ee help log' for available subcommands." + And the command should exit with code 1 + + Scenario: An unknown flag is rejected before any log is followed + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test --foo" with a timeout of 20 seconds + Then STDERR should contain "unknown --foo parameter" + And STDOUT should not contain "watching logfile" + And the command should exit with code 1 + + Scenario: A misspelt flag gets a suggestion + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test --ngix" with a timeout of 20 seconds + Then STDERR should contain "unknown --ngix parameter" + And STDERR should contain "Did you mean '--nginx'?" + And the command should exit with code 1 + + Scenario: Too many positional arguments are rejected + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test extra-arg" with a timeout of 20 seconds + Then STDERR should contain "Too many positional arguments: extra-arg" + And the command should exit with code 1 + + Scenario: Without a site name and outside a site directory the command explains how to call it + When I run "bin/ee log show" with a timeout of 20 seconds + Then STDERR should contain "Could not find the site you wish to run log show command on." + And STDERR should contain "ee log show " + And the command should exit with code 1 + + Scenario: An unknown site is reported + Given the site "bh-log-command-99.test" does not exist + When I run "bin/ee log show bh-log-command-99.test" with a timeout of 20 seconds + Then STDERR should contain "Could not find the site you wish to run log show command on." + And STDOUT should not contain "watching logfile" + And the command should exit with code 1 + + Scenario: A disabled site is refused + Given the site "bh-log-command-4.test" of type "html" exists + And the site "bh-log-command-4.test" is disabled + When I run "bin/ee log show bh-log-command-4.test" with a timeout of 20 seconds + Then STDERR should contain "Site bh-log-command-4.test is not enabled. Use `ee site enable bh-log-command-4.test` to enable it." + And STDOUT should not contain "watching logfile" + And the command should exit with code 1 + And the site "bh-log-command-4.test" should be disabled + + Scenario: The site name is detected from the current directory + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show" from "{SITES_DIR}/bh-log-command-1.test/app/htdocs" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" + And the command should still be running when the timeout expires + + Scenario: A wrong site name inside a site directory suggests the detected site + Given the site "bh-log-command-1.test" of type "html" exists + And the site "bh-log-command-99.test" does not exist + When I run "bin/ee log show bh-log-command-99.test" from "{SITES_DIR}/bh-log-command-1.test" with a timeout of 20 seconds + Then STDERR should contain "bh-log-command-99.test is not a valid site-name. Did you mean `ee log show bh-log-command-1.test`?" + And the command should exit with code 1 + + Scenario: A trailing slash on the site name is accepted + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test/" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" + And the command should still be running when the timeout expires + + # LOGCMD-8 (proposed): auto_site_name() accepts "http://" but returns the args unchanged, + # so get_site_info() looks up the raw URL and fails. + @known-bug + Scenario: A site given as a URL is resolved like the bare name + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show http://bh-log-command-1.test/" with a timeout of 8 seconds + Then STDERR should not contain "does not exist" + And STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" + And the command should still be running when the timeout expires From 513f5fa914da75df81343676aac7dd3f805ff492 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 11:43:58 +0530 Subject: [PATCH 03/22] test(log-command): cover site logs, type filters and line count --- features/log-site.feature | 339 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 features/log-site.feature diff --git a/features/log-site.feature b/features/log-site.feature new file mode 100644 index 0000000..8a18eb4 --- /dev/null +++ b/features/log-site.feature @@ -0,0 +1,339 @@ +Feature: ee log show for a single site (type filters, line count, following) + + # Sites: bh-log-command-1.test (html), bh-log-command-2.test (php), bh-log-command-3.test (wp). + # `ee log show` ends in `tail -f`, so runs are bounded by a timeout and exit 124 means "still following". + # Extra *.log files written by scenarios live inside the site's own log volume and are removed after each scenario. + + Scenario: html site default shows only the nginx logs + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile" exactly 2 times + And STDOUT should contain "==> {SITES_DIR}/bh-log-command-1.test/logs/nginx/access.log <==" + And STDOUT should contain "==> {SITES_DIR}/bh-log-command-1.test/logs/nginx/error.log <==" + And STDERR should not contain "Warning" + And the command should still be running when the timeout expires + + Scenario: php site default shows nginx and php logs without a wp warning + Given the site "bh-log-command-2.test" of type "php" exists + When I run "bin/ee log show bh-log-command-2.test" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-2.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/php/access.log" + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/php/error.log" + And STDOUT should contain "watching logfile" exactly 4 times + And STDERR should not contain "wp log doesn't exist" + And the command should still be running when the timeout expires + + Scenario: wp site default shows nginx and php logs and warns about the missing debug.log + Given the site "bh-log-command-3.test" of type "wp" exists + And the file "{SITES_DIR}/bh-log-command-3.test/app/htdocs/wp-content/debug.log" does not exist + When I run "bin/ee log show bh-log-command-3.test" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/access.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/error.log" + And STDOUT should contain "watching logfile" exactly 4 times + And STDERR should contain "Warning: wp log doesn't exist!" + And the command should still be running when the timeout expires + + Scenario: --nginx shows only the nginx logs + Given the site "bh-log-command-3.test" of type "wp" exists + When I run "bin/ee log show bh-log-command-3.test --nginx" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile" exactly 2 times + And STDOUT should not contain "/logs/php/" + And STDERR should not contain "wp log doesn't exist" + And the command should still be running when the timeout expires + + Scenario: --php shows only the php logs + Given the site "bh-log-command-3.test" of type "wp" exists + When I run "bin/ee log show bh-log-command-3.test --php" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/access.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/error.log" + And STDOUT should contain "watching logfile" exactly 2 times + And STDOUT should not contain "/logs/nginx/" + And the command should still be running when the timeout expires + + Scenario: --wp without a debug.log warns and fails + Given the site "bh-log-command-3.test" of type "wp" exists + And the file "{SITES_DIR}/bh-log-command-3.test/app/htdocs/wp-content/debug.log" does not exist + When I run "bin/ee log show bh-log-command-3.test --wp" with a timeout of 20 seconds + Then STDERR should contain "Warning: wp log doesn't exist!" + And STDERR should contain "Error: No logs found!" + And STDOUT should not contain "watching logfile" + And the command should exit with code 1 + + Scenario: --wp follows the WordPress debug.log + Given the site "bh-log-command-3.test" of type "wp" exists + And the file "{SITES_DIR}/bh-log-command-3.test/app/htdocs/wp-content/debug.log" contains: + """ + bh-wp-debug-marker + """ + When I run "bin/ee log show bh-log-command-3.test --wp" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/app/htdocs/wp-content/debug.log" + And STDOUT should contain "watching logfile" exactly 1 time + And STDOUT should contain "bh-wp-debug-marker" + And STDERR should not contain "Warning" + And the command should still be running when the timeout expires + + Scenario: --all on a wp site shows nginx, php and the debug.log + Given the site "bh-log-command-3.test" of type "wp" exists + And the file "{SITES_DIR}/bh-log-command-3.test/app/htdocs/wp-content/debug.log" contains: + """ + bh-wp-all-marker + """ + When I run "bin/ee log show bh-log-command-3.test --all" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/access.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/error.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/app/htdocs/wp-content/debug.log" + And STDOUT should contain "watching logfile" exactly 5 times + And STDOUT should contain "bh-wp-all-marker" + And the command should still be running when the timeout expires + + Scenario: --access shows the nginx and php access logs only + Given the site "bh-log-command-3.test" of type "wp" exists + When I run "bin/ee log show bh-log-command-3.test --access" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/access.log" + And STDOUT should contain "watching logfile" exactly 2 times + And STDOUT should not contain "error.log" + And the command should still be running when the timeout expires + + Scenario: --error shows the nginx and php error logs only + Given the site "bh-log-command-3.test" of type "wp" exists + When I run "bin/ee log show bh-log-command-3.test --error" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/error.log" + And STDOUT should contain "watching logfile" exactly 2 times + And STDOUT should not contain "access.log" + And the command should still be running when the timeout expires + + Scenario: --access and --error on an html site use the nginx logs only + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test --access --error" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile" exactly 2 times + And the command should still be running when the timeout expires + + Scenario: --access only picks files named access.log + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-other.log" contains: + """ + bh-other-marker + """ + When I run "bin/ee log show bh-log-command-1.test --access" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile" exactly 1 time + And STDOUT should not contain "bh-other" + And the command should still be running when the timeout expires + + Scenario: Combined type flags are merged without duplicates + Given the site "bh-log-command-3.test" of type "wp" exists + When I run "bin/ee log show bh-log-command-3.test --nginx --access" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/access.log" exactly 1 time + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/error.log" exactly 1 time + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/access.log" exactly 1 time + And STDOUT should contain "watching logfile" exactly 3 times + And the command should still be running when the timeout expires + + Scenario: --nginx --php on a php site shows both log directories + Given the site "bh-log-command-2.test" of type "php" exists + When I run "bin/ee log show bh-log-command-2.test --nginx --php" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile" exactly 4 times + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/php/error.log" + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/nginx/error.log" + And the command should still be running when the timeout expires + + Scenario Outline: A log type the site does not have fails with "No logs found!" + Given the site "" of type "" exists + When I run "bin/ee log show " with a timeout of 20 seconds + Then STDERR should contain "Error: No logs found!" + And STDOUT should not contain "watching logfile" + And the command should exit with code 1 + + Examples: + | site | type | flag | + | bh-log-command-1.test | html | --php | + | bh-log-command-1.test | html | --wp | + | bh-log-command-2.test | php | --wp | + + Scenario: Log files in subdirectories are discovered recursively + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-sub/bh-nested.log" contains: + """ + bh-nested-marker + """ + When I run "bin/ee log show bh-log-command-1.test --nginx" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/bh-sub/bh-nested.log" + And STDOUT should contain "bh-nested-marker" + And the command should still be running when the timeout expires + + Scenario: Files without a .log extension, including rotated logs, are ignored + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-notes.txt" contains: + """ + bh-notes-marker + """ + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-rotated.log.1" contains: + """ + bh-rotated-marker + """ + When I run "bin/ee log show bh-log-command-1.test --nginx" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile" exactly 2 times + And STDOUT should not contain "bh-notes" + And STDOUT should not contain "bh-rotated" + And the command should still be running when the timeout expires + + # LOGCMD-7 (proposed): get_files() reads pathinfo()['extension'] without isset(), so a file without + # an extension in a log directory raises "Undefined array key" (PHP 8) / "Undefined index" (PHP 7.4). + @known-bug + Scenario: A file without an extension in a log directory does not raise PHP warnings + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-noext" contains: + """ + bh-noext-marker + """ + When I run "bin/ee log show bh-log-command-1.test --nginx" with a timeout of 8 seconds + Then STDERR should not contain "Undefined" + And STDOUT should contain "watching logfile" exactly 2 times + And the command should still be running when the timeout expires + + # LOGCMD-3 (proposed): show() does unset( $final_types['php'], $final_types['wp'] ) on a list with integer + # keys, so the wp type is never dropped for html/php sites and a wp-content/debug.log there gets followed. + @known-bug + Scenario Outline: --wp is ignored for non-WordPress sites even when a debug.log exists + Given the site "" of type "" exists + And the file "{SITES_DIR}//app/htdocs/wp-content/debug.log" contains: + """ + bh-non-wp-debug-marker + """ + When I run "bin/ee log show --wp" with a timeout of 20 seconds + Then STDOUT should not contain "wp-content/debug.log" + And STDERR should contain "Error: No logs found!" + And the command should exit with code 1 + + Examples: + | site | type | + | bh-log-command-1.test | html | + | bh-log-command-2.test | php | + + Scenario: The default line count is 10 + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-lines.log" contains: + """ + bh-line-01 + bh-line-02 + bh-line-03 + bh-line-04 + bh-line-05 + bh-line-06 + bh-line-07 + bh-line-08 + bh-line-09 + bh-line-10 + bh-line-11 + bh-line-12 + """ + When I run "bin/ee log show bh-log-command-1.test --nginx" with a timeout of 8 seconds + Then STDOUT should contain "bh-line-03" + And STDOUT should contain "bh-line-12" + And STDOUT should not contain "bh-line-02" + And STDOUT should not contain "bh-line-01" + And the command should still be running when the timeout expires + + Scenario: --n limits the initial lines + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-lines.log" contains: + """ + bh-line-01 + bh-line-02 + bh-line-03 + bh-line-04 + bh-line-05 + """ + When I run "bin/ee log show bh-log-command-1.test --nginx --n=2" with a timeout of 8 seconds + Then STDOUT should contain "bh-line-04" + And STDOUT should contain "bh-line-05" + And STDOUT should not contain "bh-line-03" + And the command should still be running when the timeout expires + + Scenario: --n=0 shows no existing lines but still follows + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-lines.log" contains: + """ + bh-line-01 + """ + When I run "bin/ee log show bh-log-command-1.test --nginx --n=0" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/bh-lines.log" + And STDOUT should not contain "bh-line-01" + And the command should still be running when the timeout expires + + Scenario: --n without a value warns and falls back to 10 lines + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-lines.log" contains: + """ + bh-line-01 + bh-line-02 + bh-line-03 + bh-line-04 + bh-line-05 + bh-line-06 + bh-line-07 + bh-line-08 + bh-line-09 + bh-line-10 + bh-line-11 + bh-line-12 + """ + When I run "bin/ee log show bh-log-command-1.test --nginx --n" with a timeout of 8 seconds + Then STDERR should contain "Warning: --n parameter needs a value" + And STDOUT should contain "bh-line-03" + And STDOUT should not contain "bh-line-02" + And the command should still be running when the timeout expires + + # LOGCMD-2 (proposed): --n is never validated and the exit status of tail is ignored, so an invalid + # line count prints tail's error but ee still exits 0. + @known-bug + Scenario: An invalid --n value fails with a non-zero exit code + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test --nginx --n=abc" with a timeout of 20 seconds + Then STDERR should contain "abc" + And the command should exit with a non-zero code + + # LOGCMD-1 (proposed): --n and the file list are interpolated unescaped into system( "tail -n $lines -f ..." ). + @known-bug + Scenario: --n is not interpreted by the shell + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test --nginx --n='1 -q /dev/null; echo BH_INJ_$((6*7)); #'" with a timeout of 8 seconds + Then STDOUT should not contain "BH_INJ_42" + + Scenario: New lines are streamed while following + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-follow.log" contains: + """ + bh-follow-old + """ + When I run "bin/ee log show bh-log-command-1.test --nginx --n=0" with a timeout of 12 seconds while "bh-follow-new" is appended to the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-follow.log" once tail follows it + Then STDOUT should contain "bh-follow-new" + And STDOUT should not contain "bh-follow-old" + And the command should still be running when the timeout expires + + Scenario: A real request shows up in the nginx access log + Given the site "bh-log-command-1.test" of type "html" exists + Then a GET request to "http://127.0.0.1/bh-log-command-access-probe" with Host "bh-log-command-1.test" should return 404 + When I run "bin/ee log show bh-log-command-1.test --access --n=50" with a timeout of 8 seconds + Then STDOUT should contain "GET /bh-log-command-access-probe" + And the command should still be running when the timeout expires + + Scenario: --quiet hides the "watching logfile" lines but still follows + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test --nginx --quiet" with a timeout of 8 seconds + Then STDOUT should not contain "watching logfile" + And STDOUT should contain "==> {SITES_DIR}/bh-log-command-1.test/logs/nginx/access.log <==" + And the command should still be running when the timeout expires From f1a54b55e606e0b147cfd03341c28bc917228d58 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 11:49:09 +0530 Subject: [PATCH 04/22] test(log-command): cover cli and global logs --- features/log-global.feature | 102 ++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 features/log-global.feature diff --git a/features/log-global.feature b/features/log-global.feature new file mode 100644 index 0000000..4358fa2 --- /dev/null +++ b/features/log-global.feature @@ -0,0 +1,102 @@ +Feature: ee log show --cli and --global + + # --cli follows {EE_ROOT_DIR}/logs/ee.log; --global follows ee.log plus every site's logs. + # These scenarios only read ee.log; the "watching logfile" lines ee itself writes there are used as markers. + # The html site is created first on purpose: LOGCMD-4 only shows when a non-WordPress site precedes the first WordPress site. + + Scenario: --cli follows EasyEngine's own log + Given the file "{EE_ROOT_DIR}/logs/ee.log" should exist + When I run "bin/ee log show --cli" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile {EE_ROOT_DIR}/logs/ee.log" + And STDOUT should contain "ee.INFO: watching logfile {EE_ROOT_DIR}/logs/ee.log" + And the file "{EE_ROOT_DIR}/logs/ee.log" should contain "ee.INFO: watching logfile {EE_ROOT_DIR}/logs/ee.log" + And the command should still be running when the timeout expires + + Scenario: --cli honours --n + When I run "bin/ee log show --cli --n=1" with a timeout of 8 seconds + Then STDOUT should contain "ee.INFO: watching logfile {EE_ROOT_DIR}/logs/ee.log" + And STDOUT should not contain "ee invoked" + And the command should still be running when the timeout expires + + Scenario: --cli ignores a site name and type flags + Given the site "bh-log-command-1.test" of type "html" exists + When I run "bin/ee log show bh-log-command-1.test --cli --nginx" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile {EE_ROOT_DIR}/logs/ee.log" + And STDOUT should not contain "watching logfile bh-log-command-1.test" + And the command should still be running when the timeout expires + + Scenario: --global follows ee.log and the logs of every site type + Given the site "bh-log-command-1.test" of type "html" exists + And the site "bh-log-command-2.test" of type "php" exists + And the site "bh-log-command-3.test" of type "wp" exists + And the file "{SITES_DIR}/bh-log-command-3.test/app/htdocs/wp-content/debug.log" contains: + """ + bh-global-wp-marker + """ + When I run "bin/ee log show --global" with a timeout of 10 seconds + Then STDOUT should contain "watching logfile {EE_ROOT_DIR}/logs/ee.log" + And STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/nginx/access.log" + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/php/access.log" + And STDOUT should contain "watching logfile bh-log-command-2.test/logs/php/error.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/error.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/php/error.log" + And STDOUT should contain "watching logfile bh-log-command-3.test/app/htdocs/wp-content/debug.log" + And STDOUT should contain "==> {EE_ROOT_DIR}/logs/ee.log <==" + And STDOUT should contain "bh-global-wp-marker" + And STDOUT should not contain "bh-log-command-1.test/logs/php/" + And the command should still be running when the timeout expires + + Scenario: --global warns for a WordPress site without debug.log but keeps following + Given the site "bh-log-command-3.test" of type "wp" exists + And the file "{SITES_DIR}/bh-log-command-3.test/app/htdocs/wp-content/debug.log" does not exist + When I run "bin/ee log show --global" with a timeout of 10 seconds + Then STDERR should contain "Warning: wp log doesn't exist!" + And STDOUT should contain "watching logfile bh-log-command-3.test/logs/nginx/access.log" + And the command should still be running when the timeout expires + + Scenario: --global takes precedence over --cli and a site name + Given the site "bh-log-command-1.test" of type "html" exists + And the site "bh-log-command-99.test" does not exist + When I run "bin/ee log show bh-log-command-99.test --global --cli" with a timeout of 10 seconds + Then STDERR should not contain "Could not find the site" + And STDOUT should contain "watching logfile {EE_ROOT_DIR}/logs/ee.log" + And STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" + And the command should still be running when the timeout expires + + Scenario: --global includes disabled sites + Given the site "bh-log-command-4.test" of type "html" exists + And the site "bh-log-command-4.test" is disabled + When I run "bin/ee log show --global" with a timeout of 10 seconds + Then STDOUT should contain "watching logfile bh-log-command-4.test/logs/nginx/access.log" + And the command should still be running when the timeout expires + And the site "bh-log-command-4.test" should be disabled + + # LOGCMD-4 (proposed): in the --global loop $wp_logs_path is only assigned for WordPress sites, so a + # non-WordPress site before the first WordPress site hits an undefined variable (later ones reuse a stale path). + @known-bug + Scenario: --global raises no PHP warnings for non-WordPress sites + Given the site "bh-log-command-1.test" of type "html" exists + And the site "bh-log-command-3.test" of type "wp" exists + When I run "bin/ee log show --global" with a timeout of 10 seconds + Then STDERR should not contain "wp_logs_path" + And the command should still be running when the timeout expires + + # LOGCMD-5 (proposed): --global computes the requested types but never uses them, so type filters are silently ignored. + @known-bug + Scenario: --global honours a type filter + Given the site "bh-log-command-2.test" of type "php" exists + When I run "bin/ee log show --global --nginx" with a timeout of 10 seconds + Then STDOUT should contain "watching logfile bh-log-command-2.test/logs/nginx/access.log" + And STDOUT should not contain "watching logfile bh-log-command-2.test/logs/php/" + And the command should still be running when the timeout expires + + # LOGCMD-6 (proposed): the help says --global covers "all services" (and --all "service logs"), + # but no service log (e.g. nginx-proxy under {EE_ROOT_DIR}/services/nginx-proxy/logs) is ever followed. + @known-bug + Scenario: --global includes the global nginx-proxy logs + Given the file "{EE_ROOT_DIR}/services/nginx-proxy/logs/access.log" should exist + When I run "bin/ee log show --global" with a timeout of 10 seconds + Then STDOUT should contain "watching logfile {EE_ROOT_DIR}/services/nginx-proxy/logs/access.log" + And the command should still be running when the timeout expires From c46bf12a19f785c41f290edd9a5f4ace629e492e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 11:54:27 +0530 Subject: [PATCH 05/22] test(log-command): add coverage inventory --- features/COVERAGE.md | 80 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 features/COVERAGE.md diff --git a/features/COVERAGE.md b/features/COVERAGE.md new file mode 100644 index 0000000..c8d995a --- /dev/null +++ b/features/COVERAGE.md @@ -0,0 +1,80 @@ +# log-command Behat coverage + +Suite: `log.feature` (command surface, site resolution, errors), `log-site.feature` (single-site logs), `log-global.feature` (`--cli`, `--global`). Shared steps live in the marked section at the end of `bootstrap/FeatureContext.php`. + +## Inventory + +The package registers one command, `ee log`, with one subcommand, `show` (there is no `tail` subcommand: `show` itself ends in `tail -n -f ` via `system()`). + +`ee log show [] [--n=] [--global] [--cli] [--all] [--nginx] [--php] [--wp] [--access] [--error]` + +| Item | Behaviour (from `src/Log_Command.php`) | Covered by | +|---|---|---| +| `ee log` (no subcommand) | Prints usage of `show` | log.feature "ee log without a subcommand lists show" | +| `ee help log show` | Synopsis and option docs | log.feature "ee help log show documents every option" | +| `ee log tail` | Not a subcommand, framework error | log.feature "log tail is not a registered subcommand" | +| Unknown / misspelt flag | Framework "unknown --x parameter" (+ suggestion), exit 1 | log.feature "An unknown flag…", "A misspelt flag…" | +| Extra positional | "Too many positional arguments", exit 1 | log.feature "Too many positional arguments…" | +| `` omitted, outside a site dir | `auto_site_name()` error, exit 1 | log.feature "Without a site name…" | +| `` omitted, inside a site dir | Site detected from cwd | log.feature "The site name is detected from the current directory" | +| Unknown site | `auto_site_name()` error ("Could not find the site…") | log.feature "An unknown site is reported" | +| Wrong name inside a site dir | "…is not a valid site-name. Did you mean…" | log.feature "A wrong site name inside a site directory…" | +| Disabled site | `get_site_info()` "is not enabled" error | log.feature "A disabled site is refused" | +| Trailing slash in site name | Accepted | log.feature "A trailing slash…" | +| `http:///` form | Should resolve; fails (LOGCMD-8) | log.feature `@known-bug` "A site given as a URL…" | +| Default types (nginx, php, wp) on html | nginx `access.log` + `error.log` only | log-site "html site default…" | +| Default types on php | nginx + php logs, no wp warning | log-site "php site default…" | +| Default types on wp | nginx + php logs, "wp log doesn't exist!" warning when no debug.log | log-site "wp site default…" | +| `--nginx` | All `*.log` under `logs/nginx` (recursive) | log-site "--nginx…", "Log files in subdirectories…" | +| `--php` | All `*.log` under `logs/php` | log-site "--php…" | +| `--wp` | `app/htdocs/wp-content/debug.log`; warning + "No logs found!" when missing | log-site "--wp without a debug.log…", "--wp follows…" | +| `--access` | Files named `access.log` in nginx (+ php for non-html) | log-site "--access shows…", "--access only picks files named access.log", "--access and --error on an html site…" | +| `--error` | Files named `error.log` in nginx (+ php for non-html) | log-site "--error shows…", "--access and --error on an html site…" | +| `--all` | All five types | log-site "--all on a wp site…" | +| Combined type flags | Union, de-duplicated (`array_unique`) | log-site "Combined type flags…", "--nginx --php on a php site…" | +| Type the site lacks | "No logs found!", exit 1 | log-site outline "A log type the site does not have…" (html `--php`, html `--wp`, php `--wp`) | +| html/php sites drop the wp type | Intended by `unset()`, broken (LOGCMD-3) | log-site `@known-bug` outline "--wp is ignored for non-WordPress sites…" | +| Non-`.log` files and rotated `*.log.1` | Ignored | log-site "Files without a .log extension…" | +| File without extension | PHP warning (LOGCMD-7) | log-site `@known-bug` "A file without an extension…" | +| `--n` default 10 | `tail -n 10` | log-site "The default line count is 10" | +| `--n=` / `--n=0` | Initial lines limited / none | log-site "--n limits…", "--n=0…", log-global "--cli honours --n" | +| `--n` without value | Framework warning, falls back to 10 | log-site "--n without a value…" | +| `--n=abc` | tail error but exit 0 (LOGCMD-2) | log-site `@known-bug` "An invalid --n value…" | +| `--n` shell injection | Unescaped `system()` (LOGCMD-1) | log-site `@known-bug` "--n is not interpreted by the shell" | +| Following (`tail -f`) | New lines stream until killed | log-site "New lines are streamed while following"; every happy path asserts exit 124 (still running at the timeout) | +| Real nginx access log entry | HTTP request appears in `--access` output | log-site "A real request shows up in the nginx access log" | +| "watching logfile" lines | `EE::log`, path shown relative to the sites dir; hidden by `--quiet` | all happy paths; log-site "--quiet hides…" | +| `--cli` | Follows `{EE_ROOT_DIR}/logs/ee.log`; the "watching logfile" line is also written to ee.log (side effect) | log-global "--cli follows EasyEngine's own log" | +| `--cli` with a site name / type flags | Both ignored | log-global "--cli ignores a site name and type flags" | +| `--global` | ee.log + nginx/php/wp logs of every site | log-global "--global follows ee.log and the logs of every site type" | +| `--global`, WP site without debug.log | Warning, keeps following | log-global "--global warns for a WordPress site…" | +| `--global` with `--cli` and a (nonexistent) site name | `--global` wins, site name ignored (documents current behaviour) | log-global "--global takes precedence…" | +| `--global` and disabled sites | Included | log-global "--global includes disabled sites" | +| `--global` undefined `$wp_logs_path` | PHP warning (LOGCMD-4) | log-global `@known-bug` "--global raises no PHP warnings…" | +| `--global` with type filters | Filters ignored (LOGCMD-5) | log-global `@known-bug` "--global honours a type filter" | +| Service logs (help promises them for `--global`/`--all`) | Never followed (LOGCMD-6) | log-global `@known-bug` "--global includes the global nginx-proxy logs" | +| `--cli`/`--global` "cli log doesn't exist!" and global "No logs found!" | Not covered: unreachable, ee's file logger creates ee.log before the command runs | – | +| Local-DB mariadb logs (`services/mariadb/logs`) | Not covered: never read by the command (part of LOGCMD-6), and a local-DB site adds ~1 min | – | + +Side effects: the command is read-only apart from appending its "watching logfile" lines to ee.log (asserted). There are no DB rows, containers, proxy config or hooks to verify; idempotency is implicit (every scenario can be rerun; site state is asserted unchanged for the disabled site). + +Estimated coverage: about 95% of the branches of `Log_Command` (every branch except the unreachable missing-ee.log paths). + +## Suspected bugs (proposed IDs, not yet in issues.md) + +- LOGCMD-1 (Low): `--n` (and the file list) are interpolated unescaped into `system( "tail -n $lines -f $final_string" )`; shell injection via `--n`. +- LOGCMD-2 (Low): `--n` is not validated and tail's exit status is ignored, so invalid values or tail failures exit 0. +- LOGCMD-3 (Low): `unset( $final_types['php'], $final_types['wp'] )` targets string keys of a list, so it is a no-op; html/php sites follow a `wp-content/debug.log` (and an html site would follow `logs/php`). +- LOGCMD-4 (Low): `--global`: `$wp_logs_path` is only set for WP sites, so a non-WP site before the first WP site raises an undefined-variable warning and later ones reuse a stale path. +- LOGCMD-5 (Low): `--global` ignores `--nginx/--php/--wp/--access/--error` (`$final_types` unused), and it also probes nonexistent `logs/access` and `logs/error` directories. +- LOGCMD-6 (Low): help says `--global` includes "all services" and `--all` "service logs", but no service logs (nginx-proxy, local mariadb) are followed. The docblock examples also show wrong paths (`~/easyengine/sites/example.com/logs/debug.log`). +- LOGCMD-7 (Low): `pathinfo()['extension']` read without `isset()`: a file without extension in a log dir raises a PHP warning. +- LOGCMD-8 (Low, site-command helper): `auto_site_name()` accepts `http(s):///` but returns the args unchanged, so `get_site_info()` fails with "Site http://… does not exist."; site-command's own subcommands use the same pair. + +## Tags and runtime + +- `@known-bug`: 9 scenarios (8 definitions, one outline with 2 examples). Exclude with `--tags='~@known-bug'`. +- No `@le`, `@remote` or `@destructive` scenarios: the command needs no public DNS or backups and changes no global state. +- Sites (prefix `bh-log-command-`, deleted by `@AfterFeature`, which only touches that prefix): `-1` html, `-2` php, `-3` wp, `-4` html (disabled), `-99` guaranteed absent. Sites are created lazily by `Given the site … of type … exists`, so each scenario runs on its own and only the first scenario of a feature pays for creation. +- Every `ee log show` run is wrapped in `timeout` (own process group, so `tail` dies too); exit 124 means "still following". +- Estimated runtime: about 12 minutes (site creation ~4 minutes over the three features, WordPress dominating; ~54 scenarios at 3–10 s each). From 5cd70a8e61b99ee137c8a8d4facfd0ee7da8c1d5 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 11:56:57 +0530 Subject: [PATCH 06/22] test(log-command): pass the logical cwd to runs from a site directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proc_open's cwd resolves the app/ volume symlink, so get_site_name() saw /var/lib/docker/volumes/… and the site-detection scenario could never pass. Pass the logical path as PWD, as an interactive cd does. --- features/bootstrap/FeatureContext.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 601cbfe..ddc85ad 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -570,9 +570,12 @@ private function makeDir( $dir ) */ private function launchWithTimeout( $command, $seconds, $cwd = null, $before = '' ) { + // proc_open's cwd resolves symlinks (a site's app/ links to a docker volume), so also pass the + // logical path as PWD, like an interactive `cd` would; get_site_name() matches on it. $timed = sprintf( - 'timeout -k 5 %d env BEHAT_RUN=1 sh -c %s', + 'timeout -k 5 %d env BEHAT_RUN=1%s sh -c %s', (int) $seconds, + null === $cwd ? '' : ' PWD=' . escapeshellarg( $cwd ), escapeshellarg( $this->expand( $command ) ) ); if ( '' !== $before ) { From f0a4ad69a75686f5fb8df2ed6df1cb2c721935d0 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 12:01:23 +0530 Subject: [PATCH 07/22] test(log-command): cover log file name injection and tighten --cli --n Add a @known-bug scenario for the unescaped file list (LOGCMD-1), make "--cli honours --n" assert a single ee.log line instead of a check that also passed with the default, reference the issues.md IDs and state that @known-bug scenarios fail until fixed. --- features/COVERAGE.md | 16 +++++++++------- features/log-global.feature | 12 +++++++----- features/log-site.feature | 25 +++++++++++++++++++++---- features/log.feature | 3 ++- 4 files changed, 39 insertions(+), 17 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index c8d995a..806c148 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -16,7 +16,7 @@ The package registers one command, `ee log`, with one subcommand, `show` (there | Unknown / misspelt flag | Framework "unknown --x parameter" (+ suggestion), exit 1 | log.feature "An unknown flag…", "A misspelt flag…" | | Extra positional | "Too many positional arguments", exit 1 | log.feature "Too many positional arguments…" | | `` omitted, outside a site dir | `auto_site_name()` error, exit 1 | log.feature "Without a site name…" | -| `` omitted, inside a site dir | Site detected from cwd | log.feature "The site name is detected from the current directory" | +| `` omitted, inside a site dir | Site detected from cwd (`get_site_name()` matches the logical path; the step passes it as `PWD` because `app/` is a volume symlink) | log.feature "The site name is detected from the current directory" | | Unknown site | `auto_site_name()` error ("Could not find the site…") | log.feature "An unknown site is reported" | | Wrong name inside a site dir | "…is not a valid site-name. Did you mean…" | log.feature "A wrong site name inside a site directory…" | | Disabled site | `get_site_info()` "is not enabled" error | log.feature "A disabled site is refused" | @@ -37,10 +37,10 @@ The package registers one command, `ee log`, with one subcommand, `show` (there | Non-`.log` files and rotated `*.log.1` | Ignored | log-site "Files without a .log extension…" | | File without extension | PHP warning (LOGCMD-7) | log-site `@known-bug` "A file without an extension…" | | `--n` default 10 | `tail -n 10` | log-site "The default line count is 10" | -| `--n=` / `--n=0` | Initial lines limited / none | log-site "--n limits…", "--n=0…", log-global "--cli honours --n" | +| `--n=` / `--n=0` | Initial lines limited / none | log-site "--n limits…", "--n=0…", log-global "--cli honours --n" (exactly one ee.log line) | | `--n` without value | Framework warning, falls back to 10 | log-site "--n without a value…" | | `--n=abc` | tail error but exit 0 (LOGCMD-2) | log-site `@known-bug` "An invalid --n value…" | -| `--n` shell injection | Unescaped `system()` (LOGCMD-1) | log-site `@known-bug` "--n is not interpreted by the shell" | +| Shell injection via `--n` or a log file name | Unescaped `system()` (LOGCMD-1) | log-site `@known-bug` "--n is not interpreted by the shell", "A log file name is not interpreted by the shell" | | Following (`tail -f`) | New lines stream until killed | log-site "New lines are streamed while following"; every happy path asserts exit 124 (still running at the timeout) | | Real nginx access log entry | HTTP request appears in `--access` output | log-site "A real request shows up in the nginx access log" | | "watching logfile" lines | `EE::log`, path shown relative to the sites dir; hidden by `--quiet` | all happy paths; log-site "--quiet hides…" | @@ -60,9 +60,11 @@ Side effects: the command is read-only apart from appending its "watching logfil Estimated coverage: about 95% of the branches of `Log_Command` (every branch except the unreachable missing-ee.log paths). -## Suspected bugs (proposed IDs, not yet in issues.md) +## Known bugs (`## log-command` in issues.md) -- LOGCMD-1 (Low): `--n` (and the file list) are interpolated unescaped into `system( "tail -n $lines -f $final_string" )`; shell injection via `--n`. +`@known-bug` scenarios assert the fixed behaviour, so they fail until the bug is fixed. + +- LOGCMD-1 (High): `--n` and the file list are interpolated unescaped into `system( "tail -n $lines -f $final_string" )`. `logs/php` is writable by www-data in the php container, so a planted file name like `x$(cmd).log` runs as root on the host when the admin follows the logs. - LOGCMD-2 (Low): `--n` is not validated and tail's exit status is ignored, so invalid values or tail failures exit 0. - LOGCMD-3 (Low): `unset( $final_types['php'], $final_types['wp'] )` targets string keys of a list, so it is a no-op; html/php sites follow a `wp-content/debug.log` (and an html site would follow `logs/php`). - LOGCMD-4 (Low): `--global`: `$wp_logs_path` is only set for WP sites, so a non-WP site before the first WP site raises an undefined-variable warning and later ones reuse a stale path. @@ -73,8 +75,8 @@ Estimated coverage: about 95% of the branches of `Log_Command` (every branch exc ## Tags and runtime -- `@known-bug`: 9 scenarios (8 definitions, one outline with 2 examples). Exclude with `--tags='~@known-bug'`. +- `@known-bug`: 10 scenarios (9 definitions, one outline with 2 examples). Exclude with `--tags='~@known-bug'`. - No `@le`, `@remote` or `@destructive` scenarios: the command needs no public DNS or backups and changes no global state. - Sites (prefix `bh-log-command-`, deleted by `@AfterFeature`, which only touches that prefix): `-1` html, `-2` php, `-3` wp, `-4` html (disabled), `-99` guaranteed absent. Sites are created lazily by `Given the site … of type … exists`, so each scenario runs on its own and only the first scenario of a feature pays for creation. - Every `ee log show` run is wrapped in `timeout` (own process group, so `tail` dies too); exit 124 means "still following". -- Estimated runtime: about 12 minutes (site creation ~4 minutes over the three features, WordPress dominating; ~54 scenarios at 3–10 s each). +- Estimated runtime: about 12 minutes (site creation ~4 minutes over the three features, WordPress dominating; 55 scenarios at 3–10 s each). diff --git a/features/log-global.feature b/features/log-global.feature index 4358fa2..4388fbe 100644 --- a/features/log-global.feature +++ b/features/log-global.feature @@ -2,6 +2,7 @@ Feature: ee log show --cli and --global # --cli follows {EE_ROOT_DIR}/logs/ee.log; --global follows ee.log plus every site's logs. # These scenarios only read ee.log; the "watching logfile" lines ee itself writes there are used as markers. +# @known-bug scenarios assert the fixed behaviour, so they fail until the referenced issues.md entry is fixed. # The html site is created first on purpose: LOGCMD-4 only shows when a non-WordPress site precedes the first WordPress site. Scenario: --cli follows EasyEngine's own log @@ -15,7 +16,7 @@ Feature: ee log show --cli and --global Scenario: --cli honours --n When I run "bin/ee log show --cli --n=1" with a timeout of 8 seconds Then STDOUT should contain "ee.INFO: watching logfile {EE_ROOT_DIR}/logs/ee.log" - And STDOUT should not contain "ee invoked" + And STDOUT should contain "] ee." exactly 1 time And the command should still be running when the timeout expires Scenario: --cli ignores a site name and type flags @@ -73,8 +74,9 @@ Feature: ee log show --cli and --global And the command should still be running when the timeout expires And the site "bh-log-command-4.test" should be disabled - # LOGCMD-4 (proposed): in the --global loop $wp_logs_path is only assigned for WordPress sites, so a - # non-WordPress site before the first WordPress site hits an undefined variable (later ones reuse a stale path). + # LOGCMD-4: in the --global loop $wp_logs_path is only assigned for WordPress sites, so a non-WordPress site + # before the first WordPress site hits an undefined variable (later ones reuse a stale path). Only exposed while + # the html site's DB row precedes the wp site's (true when the feature runs in order). @known-bug Scenario: --global raises no PHP warnings for non-WordPress sites Given the site "bh-log-command-1.test" of type "html" exists @@ -83,7 +85,7 @@ Feature: ee log show --cli and --global Then STDERR should not contain "wp_logs_path" And the command should still be running when the timeout expires - # LOGCMD-5 (proposed): --global computes the requested types but never uses them, so type filters are silently ignored. + # LOGCMD-5: --global computes the requested types but never uses them, so type filters are silently ignored. @known-bug Scenario: --global honours a type filter Given the site "bh-log-command-2.test" of type "php" exists @@ -92,7 +94,7 @@ Feature: ee log show --cli and --global And STDOUT should not contain "watching logfile bh-log-command-2.test/logs/php/" And the command should still be running when the timeout expires - # LOGCMD-6 (proposed): the help says --global covers "all services" (and --all "service logs"), + # LOGCMD-6: the help says --global covers "all services" (and --all "service logs"), # but no service log (e.g. nginx-proxy under {EE_ROOT_DIR}/services/nginx-proxy/logs) is ever followed. @known-bug Scenario: --global includes the global nginx-proxy logs diff --git a/features/log-site.feature b/features/log-site.feature index 8a18eb4..71c2b2d 100644 --- a/features/log-site.feature +++ b/features/log-site.feature @@ -3,6 +3,7 @@ Feature: ee log show for a single site (type filters, line count, following) # Sites: bh-log-command-1.test (html), bh-log-command-2.test (php), bh-log-command-3.test (wp). # `ee log show` ends in `tail -f`, so runs are bounded by a timeout and exit 124 means "still following". # Extra *.log files written by scenarios live inside the site's own log volume and are removed after each scenario. +# @known-bug scenarios assert the fixed behaviour, so they fail until the referenced issues.md entry is fixed. Scenario: html site default shows only the nginx logs Given the site "bh-log-command-1.test" of type "html" exists @@ -190,7 +191,7 @@ Feature: ee log show for a single site (type filters, line count, following) And STDOUT should not contain "bh-rotated" And the command should still be running when the timeout expires - # LOGCMD-7 (proposed): get_files() reads pathinfo()['extension'] without isset(), so a file without + # LOGCMD-7: get_files() reads pathinfo()['extension'] without isset(), so a file without # an extension in a log directory raises "Undefined array key" (PHP 8) / "Undefined index" (PHP 7.4). @known-bug Scenario: A file without an extension in a log directory does not raise PHP warnings @@ -204,7 +205,7 @@ Feature: ee log show for a single site (type filters, line count, following) And STDOUT should contain "watching logfile" exactly 2 times And the command should still be running when the timeout expires - # LOGCMD-3 (proposed): show() does unset( $final_types['php'], $final_types['wp'] ) on a list with integer + # LOGCMD-3: show() does unset( $final_types['php'], $final_types['wp'] ) on a list with integer # keys, so the wp type is never dropped for html/php sites and a wp-content/debug.log there gets followed. @known-bug Scenario Outline: --wp is ignored for non-WordPress sites even when a debug.log exists @@ -297,7 +298,7 @@ Feature: ee log show for a single site (type filters, line count, following) And STDOUT should not contain "bh-line-02" And the command should still be running when the timeout expires - # LOGCMD-2 (proposed): --n is never validated and the exit status of tail is ignored, so an invalid + # LOGCMD-2: --n is never validated and the exit status of tail is ignored, so an invalid # line count prints tail's error but ee still exits 0. @known-bug Scenario: An invalid --n value fails with a non-zero exit code @@ -306,13 +307,29 @@ Feature: ee log show for a single site (type filters, line count, following) Then STDERR should contain "abc" And the command should exit with a non-zero code - # LOGCMD-1 (proposed): --n and the file list are interpolated unescaped into system( "tail -n $lines -f ..." ). + # LOGCMD-1: --n and the file list are interpolated unescaped into system( "tail -n $lines -f ..." ). @known-bug Scenario: --n is not interpreted by the shell Given the site "bh-log-command-1.test" of type "html" exists When I run "bin/ee log show bh-log-command-1.test --nginx --n='1 -q /dev/null; echo BH_INJ_$((6*7)); #'" with a timeout of 8 seconds Then STDOUT should not contain "BH_INJ_42" + # LOGCMD-1: log file names are interpolated unescaped too; logs/php is writable by www-data in the php + # container, so a site's PHP code can plant a name that runs as root on the host when the admin follows the logs. + @known-bug + Scenario: A log file name is not interpreted by the shell + Given the site "bh-log-command-2.test" of type "php" exists + And the file "{SITES_DIR}/bh-log-command-2.test/logs/php/bh-inj$(echo${IFS}BH_FILE_INJ_$((6*7))).log" contains: + """ + bh-inj-marker + """ + When I run "bin/ee log show bh-log-command-2.test --php" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-2.test/logs/php/access.log" + And STDOUT should not contain "BH_FILE_INJ_42" + And STDERR should not contain "BH_FILE_INJ_42" + And STDOUT should contain "bh-inj-marker" + And the command should still be running when the timeout expires + Scenario: New lines are streamed while following Given the site "bh-log-command-1.test" of type "html" exists And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-follow.log" contains: diff --git a/features/log.feature b/features/log.feature index cb0159e..9822cb8 100644 --- a/features/log.feature +++ b/features/log.feature @@ -2,6 +2,7 @@ Feature: ee log command surface, site resolution and validation errors # `ee log show` ends in `tail -f`, so every invocation that can reach tail runs under a timeout; # exit code 124 ("still running when the timeout expired") is the success signal for following. +# @known-bug scenarios assert the fixed behaviour, so they fail until the referenced issues.md entry is fixed. Scenario: ee log without a subcommand lists show When I run 'bin/ee log' @@ -81,7 +82,7 @@ Feature: ee log command surface, site resolution and validation errors Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" And the command should still be running when the timeout expires - # LOGCMD-8 (proposed): auto_site_name() accepts "http://" but returns the args unchanged, + # LOGCMD-8: auto_site_name() accepts "http://" but returns the args unchanged, # so get_site_info() looks up the raw URL and fails. @known-bug Scenario: A site given as a URL is resolved like the bare name From d5266e366e134fac40c0c10b4de365ac039550d5 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 12:04:19 +0530 Subject: [PATCH 08/22] test(log-command): tag the --quiet scenario as a known core bug (CORE-3) --- features/COVERAGE.md | 5 +++-- features/log-site.feature | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 806c148..908fd35 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -43,7 +43,7 @@ The package registers one command, `ee log`, with one subcommand, `show` (there | Shell injection via `--n` or a log file name | Unescaped `system()` (LOGCMD-1) | log-site `@known-bug` "--n is not interpreted by the shell", "A log file name is not interpreted by the shell" | | Following (`tail -f`) | New lines stream until killed | log-site "New lines are streamed while following"; every happy path asserts exit 124 (still running at the timeout) | | Real nginx access log entry | HTTP request appears in `--access` output | log-site "A real request shows up in the nginx access log" | -| "watching logfile" lines | `EE::log`, path shown relative to the sites dir; hidden by `--quiet` | all happy paths; log-site "--quiet hides…" | +| "watching logfile" lines | `EE::log`, path shown relative to the sites dir; should be hidden by `--quiet`, isn't (core CORE-3) | all happy paths; log-site `@known-bug` "--quiet hides…" | | `--cli` | Follows `{EE_ROOT_DIR}/logs/ee.log`; the "watching logfile" line is also written to ee.log (side effect) | log-global "--cli follows EasyEngine's own log" | | `--cli` with a site name / type flags | Both ignored | log-global "--cli ignores a site name and type flags" | | `--global` | ee.log + nginx/php/wp logs of every site | log-global "--global follows ee.log and the logs of every site type" | @@ -72,10 +72,11 @@ Estimated coverage: about 95% of the branches of `Log_Command` (every branch exc - LOGCMD-6 (Low): help says `--global` includes "all services" and `--all` "service logs", but no service logs (nginx-proxy, local mariadb) are followed. The docblock examples also show wrong paths (`~/easyengine/sites/example.com/logs/debug.log`). - LOGCMD-7 (Low): `pathinfo()['extension']` read without `isset()`: a file without extension in a log dir raises a PHP warning. - LOGCMD-8 (Low, site-command helper): `auto_site_name()` accepts `http(s):///` but returns the args unchanged, so `get_site_info()` fails with "Site http://… does not exist."; site-command's own subcommands use the same pair. +- CORE-3 (Low, core): the logger is set up before the config and arguments are parsed, so `--quiet` never hides the "watching logfile" lines (found on the CI runner, https://github.com/mrrobot47/log-command/actions/runs/36085159478). ## Tags and runtime -- `@known-bug`: 10 scenarios (9 definitions, one outline with 2 examples). Exclude with `--tags='~@known-bug'`. +- `@known-bug`: 11 scenarios (10 definitions, one outline with 2 examples). Exclude with `--tags='~@known-bug'`. - No `@le`, `@remote` or `@destructive` scenarios: the command needs no public DNS or backups and changes no global state. - Sites (prefix `bh-log-command-`, deleted by `@AfterFeature`, which only touches that prefix): `-1` html, `-2` php, `-3` wp, `-4` html (disabled), `-99` guaranteed absent. Sites are created lazily by `Given the site … of type … exists`, so each scenario runs on its own and only the first scenario of a feature pays for creation. - Every `ee log show` run is wrapped in `timeout` (own process group, so `tail` dies too); exit 124 means "still following". diff --git a/features/log-site.feature b/features/log-site.feature index 71c2b2d..f97ceb4 100644 --- a/features/log-site.feature +++ b/features/log-site.feature @@ -348,6 +348,8 @@ Feature: ee log show for a single site (type filters, line count, following) Then STDOUT should contain "GET /bh-log-command-access-probe" And the command should still be running when the timeout expires + # CORE-3: the logger is chosen before the config is parsed, so --quiet never takes effect. + @known-bug Scenario: --quiet hides the "watching logfile" lines but still follows Given the site "bh-log-command-1.test" of type "html" exists When I run "bin/ee log show bh-log-command-1.test --nginx --quiet" with a timeout of 8 seconds From 69269f42ce96ac59522e5505d8d1c12ba70008e3 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 12:07:42 +0530 Subject: [PATCH 09/22] test(log-command): run the LOGCMD-1/2 scenarios as regular ones The shell injection and --n validation fixes are on fix/escape-log-tail. The --n injection scenario now also checks the up-front rejection. --- features/COVERAGE.md | 10 +++++----- features/log-site.feature | 18 +++++++++--------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 908fd35..b0fdf94 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -39,8 +39,8 @@ The package registers one command, `ee log`, with one subcommand, `show` (there | `--n` default 10 | `tail -n 10` | log-site "The default line count is 10" | | `--n=` / `--n=0` | Initial lines limited / none | log-site "--n limits…", "--n=0…", log-global "--cli honours --n" (exactly one ee.log line) | | `--n` without value | Framework warning, falls back to 10 | log-site "--n without a value…" | -| `--n=abc` | tail error but exit 0 (LOGCMD-2) | log-site `@known-bug` "An invalid --n value…" | -| Shell injection via `--n` or a log file name | Unescaped `system()` (LOGCMD-1) | log-site `@known-bug` "--n is not interpreted by the shell", "A log file name is not interpreted by the shell" | +| `--n=abc` | Rejected up front, exit 1 (LOGCMD-2, fixed on `fix/escape-log-tail`) | log-site "An invalid --n value…" | +| Shell injection via `--n` or a log file name | tail runs without a shell (LOGCMD-1, fixed on `fix/escape-log-tail`) | log-site "--n is not interpreted by the shell", "A log file name is not interpreted by the shell" | | Following (`tail -f`) | New lines stream until killed | log-site "New lines are streamed while following"; every happy path asserts exit 124 (still running at the timeout) | | Real nginx access log entry | HTTP request appears in `--access` output | log-site "A real request shows up in the nginx access log" | | "watching logfile" lines | `EE::log`, path shown relative to the sites dir; should be hidden by `--quiet`, isn't (core CORE-3) | all happy paths; log-site `@known-bug` "--quiet hides…" | @@ -64,8 +64,8 @@ Estimated coverage: about 95% of the branches of `Log_Command` (every branch exc `@known-bug` scenarios assert the fixed behaviour, so they fail until the bug is fixed. -- LOGCMD-1 (High): `--n` and the file list are interpolated unescaped into `system( "tail -n $lines -f $final_string" )`. `logs/php` is writable by www-data in the php container, so a planted file name like `x$(cmd).log` runs as root on the host when the admin follows the logs. -- LOGCMD-2 (Low): `--n` is not validated and tail's exit status is ignored, so invalid values or tail failures exit 0. +- LOGCMD-1 (High): `--n` and the file list were interpolated unescaped into `system( "tail -n $lines -f $final_string" )`. `logs/php` is writable by www-data in the php container, so a planted file name like `x$(cmd).log` ran as root on the host when the admin followed the logs. Fixed on `fix/escape-log-tail` (not merged yet); its scenarios are no longer `@known-bug`, so they fail against a develop without the fix. +- LOGCMD-2 (Low): `--n` was not validated and tail's exit status was ignored, so invalid values or tail failures exited 0. Fixed with LOGCMD-1. - LOGCMD-3 (Low): `unset( $final_types['php'], $final_types['wp'] )` targets string keys of a list, so it is a no-op; html/php sites follow a `wp-content/debug.log` (and an html site would follow `logs/php`). - LOGCMD-4 (Low): `--global`: `$wp_logs_path` is only set for WP sites, so a non-WP site before the first WP site raises an undefined-variable warning and later ones reuse a stale path. - LOGCMD-5 (Low): `--global` ignores `--nginx/--php/--wp/--access/--error` (`$final_types` unused), and it also probes nonexistent `logs/access` and `logs/error` directories. @@ -76,7 +76,7 @@ Estimated coverage: about 95% of the branches of `Log_Command` (every branch exc ## Tags and runtime -- `@known-bug`: 11 scenarios (10 definitions, one outline with 2 examples). Exclude with `--tags='~@known-bug'`. +- `@known-bug`: 8 scenarios (7 definitions, one outline with 2 examples). Exclude with `--tags='~@known-bug'`. - No `@le`, `@remote` or `@destructive` scenarios: the command needs no public DNS or backups and changes no global state. - Sites (prefix `bh-log-command-`, deleted by `@AfterFeature`, which only touches that prefix): `-1` html, `-2` php, `-3` wp, `-4` html (disabled), `-99` guaranteed absent. Sites are created lazily by `Given the site … of type … exists`, so each scenario runs on its own and only the first scenario of a feature pays for creation. - Every `ee log show` run is wrapped in `timeout` (own process group, so `tail` dies too); exit 124 means "still following". diff --git a/features/log-site.feature b/features/log-site.feature index f97ceb4..731aadc 100644 --- a/features/log-site.feature +++ b/features/log-site.feature @@ -298,25 +298,25 @@ Feature: ee log show for a single site (type filters, line count, following) And STDOUT should not contain "bh-line-02" And the command should still be running when the timeout expires - # LOGCMD-2: --n is never validated and the exit status of tail is ignored, so an invalid - # line count prints tail's error but ee still exits 0. - @known-bug + # LOGCMD-2: --n was never validated and the exit status of tail was ignored, so an invalid + # line count printed tail's error but ee still exited 0. Scenario: An invalid --n value fails with a non-zero exit code Given the site "bh-log-command-1.test" of type "html" exists When I run "bin/ee log show bh-log-command-1.test --nginx --n=abc" with a timeout of 20 seconds - Then STDERR should contain "abc" + Then STDERR should contain "Invalid --n value 'abc'" + And STDOUT should not contain "watching logfile" And the command should exit with a non-zero code - # LOGCMD-1: --n and the file list are interpolated unescaped into system( "tail -n $lines -f ..." ). - @known-bug + # LOGCMD-1: --n and the file list were interpolated unescaped into system( "tail -n $lines -f ..." ). Scenario: --n is not interpreted by the shell Given the site "bh-log-command-1.test" of type "html" exists When I run "bin/ee log show bh-log-command-1.test --nginx --n='1 -q /dev/null; echo BH_INJ_$((6*7)); #'" with a timeout of 8 seconds Then STDOUT should not contain "BH_INJ_42" + And STDERR should contain "Invalid --n value" + And the command should exit with code 1 - # LOGCMD-1: log file names are interpolated unescaped too; logs/php is writable by www-data in the php - # container, so a site's PHP code can plant a name that runs as root on the host when the admin follows the logs. - @known-bug + # LOGCMD-1: log file names were interpolated unescaped too; logs/php is writable by www-data in the php + # container, so a site's PHP code could plant a name that ran as root on the host when the admin followed the logs. Scenario: A log file name is not interpreted by the shell Given the site "bh-log-command-2.test" of type "php" exists And the file "{SITES_DIR}/bh-log-command-2.test/logs/php/bh-inj$(echo${IFS}BH_FILE_INJ_$((6*7))).log" contains: From dca46fc0ff6a5a50c0fddcb9161ca2eb0d3897bf Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 12:12:22 +0530 Subject: [PATCH 10/22] test(log-command): cover log links leaving the site's log directory (LOGCMD-9) Also runs the LOGCMD-4 --global scenario as a regular one: the LOGCMD-9 fix stops reading $wp_logs_path for non-WordPress sites. --- features/COVERAGE.md | 10 +++++---- features/bootstrap/FeatureContext.php | 32 +++++++++++++++++++++++++++ features/log-global.feature | 3 +-- features/log-site.feature | 30 +++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/features/COVERAGE.md b/features/COVERAGE.md index b0fdf94..632b088 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -43,6 +43,7 @@ The package registers one command, `ee log`, with one subcommand, `show` (there | Shell injection via `--n` or a log file name | tail runs without a shell (LOGCMD-1, fixed on `fix/escape-log-tail`) | log-site "--n is not interpreted by the shell", "A log file name is not interpreted by the shell" | | Following (`tail -f`) | New lines stream until killed | log-site "New lines are streamed while following"; every happy path asserts exit 124 (still running at the timeout) | | Real nginx access log entry | HTTP request appears in `--access` output | log-site "A real request shows up in the nginx access log" | +| Links in a site log directory | Followed only if the real path is a regular file inside the log directory (the app volume for `debug.log`), otherwise skipped with a warning (LOGCMD-9, fixed on `fix/log-symlink-escape`) | log-site outline "A log link that leaves the site's log directory is skipped" (php `logs/php`, html nested `logs/nginx`, wp `debug.log`), "A log link inside the site's log directory is still followed" | | "watching logfile" lines | `EE::log`, path shown relative to the sites dir; should be hidden by `--quiet`, isn't (core CORE-3) | all happy paths; log-site `@known-bug` "--quiet hides…" | | `--cli` | Follows `{EE_ROOT_DIR}/logs/ee.log`; the "watching logfile" line is also written to ee.log (side effect) | log-global "--cli follows EasyEngine's own log" | | `--cli` with a site name / type flags | Both ignored | log-global "--cli ignores a site name and type flags" | @@ -50,7 +51,7 @@ The package registers one command, `ee log`, with one subcommand, `show` (there | `--global`, WP site without debug.log | Warning, keeps following | log-global "--global warns for a WordPress site…" | | `--global` with `--cli` and a (nonexistent) site name | `--global` wins, site name ignored (documents current behaviour) | log-global "--global takes precedence…" | | `--global` and disabled sites | Included | log-global "--global includes disabled sites" | -| `--global` undefined `$wp_logs_path` | PHP warning (LOGCMD-4) | log-global `@known-bug` "--global raises no PHP warnings…" | +| `--global` undefined `$wp_logs_path` | No PHP warning (LOGCMD-4, fixed on `fix/log-symlink-escape`) | log-global "--global raises no PHP warnings…" | | `--global` with type filters | Filters ignored (LOGCMD-5) | log-global `@known-bug` "--global honours a type filter" | | Service logs (help promises them for `--global`/`--all`) | Never followed (LOGCMD-6) | log-global `@known-bug` "--global includes the global nginx-proxy logs" | | `--cli`/`--global` "cli log doesn't exist!" and global "No logs found!" | Not covered: unreachable, ee's file logger creates ee.log before the command runs | – | @@ -67,17 +68,18 @@ Estimated coverage: about 95% of the branches of `Log_Command` (every branch exc - LOGCMD-1 (High): `--n` and the file list were interpolated unescaped into `system( "tail -n $lines -f $final_string" )`. `logs/php` is writable by www-data in the php container, so a planted file name like `x$(cmd).log` ran as root on the host when the admin followed the logs. Fixed on `fix/escape-log-tail` (not merged yet); its scenarios are no longer `@known-bug`, so they fail against a develop without the fix. - LOGCMD-2 (Low): `--n` was not validated and tail's exit status was ignored, so invalid values or tail failures exited 0. Fixed with LOGCMD-1. - LOGCMD-3 (Low): `unset( $final_types['php'], $final_types['wp'] )` targets string keys of a list, so it is a no-op; html/php sites follow a `wp-content/debug.log` (and an html site would follow `logs/php`). -- LOGCMD-4 (Low): `--global`: `$wp_logs_path` is only set for WP sites, so a non-WP site before the first WP site raises an undefined-variable warning and later ones reuse a stale path. +- LOGCMD-4 (Low): `--global`: `$wp_logs_path` is only set for WP sites, so a non-WP site before the first WP site raises an undefined-variable warning and later ones reuse a stale path. Fixed on `fix/log-symlink-escape` (with LOGCMD-9, on top of `fix/escape-log-tail`); its scenario is no longer `@known-bug`. - LOGCMD-5 (Low): `--global` ignores `--nginx/--php/--wp/--access/--error` (`$final_types` unused), and it also probes nonexistent `logs/access` and `logs/error` directories. - LOGCMD-6 (Low): help says `--global` includes "all services" and `--all` "service logs", but no service logs (nginx-proxy, local mariadb) are followed. The docblock examples also show wrong paths (`~/easyengine/sites/example.com/logs/debug.log`). - LOGCMD-7 (Low): `pathinfo()['extension']` read without `isset()`: a file without extension in a log dir raises a PHP warning. - LOGCMD-8 (Low, site-command helper): `auto_site_name()` accepts `http(s):///` but returns the args unchanged, so `get_site_info()` fails with "Site http://… does not exist."; site-command's own subcommands use the same pair. +- LOGCMD-9 (Low): links in a site log directory (writable by the site's containers) were followed by the root `tail` on the host. Fixed on `fix/log-symlink-escape`; covered by regular scenarios that fail on a develop without the fix. - CORE-3 (Low, core): the logger is set up before the config and arguments are parsed, so `--quiet` never hides the "watching logfile" lines (found on the CI runner, https://github.com/mrrobot47/log-command/actions/runs/36085159478). ## Tags and runtime -- `@known-bug`: 8 scenarios (7 definitions, one outline with 2 examples). Exclude with `--tags='~@known-bug'`. +- `@known-bug`: 7 scenarios (6 definitions, one outline with 2 examples). Exclude with `--tags='~@known-bug'`. - No `@le`, `@remote` or `@destructive` scenarios: the command needs no public DNS or backups and changes no global state. - Sites (prefix `bh-log-command-`, deleted by `@AfterFeature`, which only touches that prefix): `-1` html, `-2` php, `-3` wp, `-4` html (disabled), `-99` guaranteed absent. Sites are created lazily by `Given the site … of type … exists`, so each scenario runs on its own and only the first scenario of a feature pays for creation. - Every `ee log show` run is wrapped in `timeout` (own process group, so `tail` dies too); exit 124 means "still following". -- Estimated runtime: about 12 minutes (site creation ~4 minutes over the three features, WordPress dominating; 55 scenarios at 3–10 s each). +- Estimated runtime: about 12 minutes (site creation ~4 minutes over the three features, WordPress dominating; 59 scenarios at 3–10 s each). diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index ddc85ad..806477c 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -676,6 +676,38 @@ public function theFileContains( $path, PyStringNode $content ) } } + /** + * Plants a link the way the site's own containers could, pointing at a marker file outside the site. + * + * @Given the file :path is a link to a file outside the site containing :text + */ + public function theFileIsALinkToAFileOutsideTheSite( $path, $text ) + { + $path = $this->expand( $path ); + self::assertOwnPath( $path ); + $this->makeDir( dirname( $path ) ); + $target = tempnam( sys_get_temp_dir(), 'bh-log-outside-' ); + $this->created_paths[] = $target; + if ( false === file_put_contents( $target, $text . "\n" ) || ! symlink( $target, $path ) ) { + throw new Exception( "Could not link $path to $target" ); + } + $this->created_paths[] = $path; + } + + /** + * @Given the file :path is a link to :target + */ + public function theFileIsALinkTo( $path, $target ) + { + $path = $this->expand( $path ); + self::assertOwnPath( $path ); + $this->makeDir( dirname( $path ) ); + if ( ! symlink( $this->expand( $target ), $path ) ) { + throw new Exception( "Could not link $path to $target" ); + } + $this->created_paths[] = $path; + } + /** * @Given the file :path does not exist */ diff --git a/features/log-global.feature b/features/log-global.feature index 4388fbe..ef713ce 100644 --- a/features/log-global.feature +++ b/features/log-global.feature @@ -76,8 +76,7 @@ Feature: ee log show --cli and --global # LOGCMD-4: in the --global loop $wp_logs_path is only assigned for WordPress sites, so a non-WordPress site # before the first WordPress site hits an undefined variable (later ones reuse a stale path). Only exposed while - # the html site's DB row precedes the wp site's (true when the feature runs in order). - @known-bug + # the html site's DB row precedes the wp site's (true when the feature runs in order). Fixed with LOGCMD-9. Scenario: --global raises no PHP warnings for non-WordPress sites Given the site "bh-log-command-1.test" of type "html" exists And the site "bh-log-command-3.test" of type "wp" exists diff --git a/features/log-site.feature b/features/log-site.feature index 731aadc..f384bb2 100644 --- a/features/log-site.feature +++ b/features/log-site.feature @@ -330,6 +330,36 @@ Feature: ee log show for a single site (type filters, line count, following) And STDOUT should contain "bh-inj-marker" And the command should still be running when the timeout expires + # LOGCMD-9: the site's containers can write to its log directories and wp-content, and ee log show runs tail as + # root on the host, so a link planted there must not be followed out of the directory. + Scenario Outline: A log link that leaves the site's log directory is skipped + Given the site "" of type "" exists + And the file "{SITES_DIR}//" is a link to a file outside the site containing "bh-outside-marker" + When I run "bin/ee log show " with a timeout of 8 seconds + Then STDOUT should not contain "bh-outside-marker" + And STDOUT should not contain "watching logfile /" + And STDERR should contain "Warning: Skipping /: it is not a regular file inside the log directory." + And the command should still be running when the timeout expires + + Examples: + | site | type | link | flag | + | bh-log-command-2.test | php | logs/php/bh-outside.log | --php | + | bh-log-command-1.test | html | logs/nginx/bh-sub/bh-outside.log | --nginx | + | bh-log-command-3.test | wp | app/htdocs/wp-content/debug.log | --all | + + Scenario: A log link inside the site's log directory is still followed + Given the site "bh-log-command-1.test" of type "html" exists + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-real.log" contains: + """ + bh-inside-marker + """ + And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-current.log" is a link to "bh-real.log" + When I run "bin/ee log show bh-log-command-1.test --nginx" with a timeout of 8 seconds + Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/bh-current.log" + And STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/bh-real.log" + And STDERR should not contain "Skipping" + And the command should still be running when the timeout expires + Scenario: New lines are streamed while following Given the site "bh-log-command-1.test" of type "html" exists And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-follow.log" contains: From dffbee16215d627dd66f89cbc3493717c74e8370 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 12:14:58 +0530 Subject: [PATCH 11/22] test(log-command): don't expect the INFO line to be the last ee.log line in the --n check --- features/log-global.feature | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/log-global.feature b/features/log-global.feature index ef713ce..eec90f4 100644 --- a/features/log-global.feature +++ b/features/log-global.feature @@ -13,9 +13,10 @@ Feature: ee log show --cli and --global And the file "{EE_ROOT_DIR}/logs/ee.log" should contain "ee.INFO: watching logfile {EE_ROOT_DIR}/logs/ee.log" And the command should still be running when the timeout expires + # The last ee.log line is whatever ee logged last (the tail command's debug line since log-command#8), not the INFO line. Scenario: --cli honours --n When I run "bin/ee log show --cli --n=1" with a timeout of 8 seconds - Then STDOUT should contain "ee.INFO: watching logfile {EE_ROOT_DIR}/logs/ee.log" + Then STDOUT should contain "watching logfile {EE_ROOT_DIR}/logs/ee.log" And STDOUT should contain "] ee." exactly 1 time And the command should still be running when the timeout expires From 64a23fdcbdf15972b155535715c8cee1eab8cb75 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 19:25:51 +0530 Subject: [PATCH 12/22] ci: run the behat suite on GitHub Actions --- .github/workflows/behat.yml | 167 ++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 .github/workflows/behat.yml diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml new file mode 100644 index 0000000..0544149 --- /dev/null +++ b/.github/workflows/behat.yml @@ -0,0 +1,167 @@ +# Behat suite of an EasyEngine package, run inside a fresh core checkout on a throwaway runner. +name: Behat + +on: + pull_request: + push: + branches: + - develop + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: behat-${{ github.ref }} + cancel-in-progress: true + +env: + CORE_REF: develop + COMPOSE_VERSION: v5.5.1 + # Not RCLONE_*: rclone reads those variables as its own flags. + RCLONE_RELEASE: v1.75.1 + # No public DNS (Let's Encrypt) or real backup storage on hosted runners. + EXCLUDE_TAGS: '~@le&&~@remote-real' + # Local rclone alias written to EE's rclone-path; empty = no rclone. + EE_RCLONE_PATH: '' + COMPOSE_INTERACTIVE_NO_CLI: 1 + RCLONE_CONFIG: /etc/rclone/rclone.conf + EE_BEHAT_DESTRUCTIVE: 1 + +jobs: + behat: + name: Behat ${{ matrix.lane }} (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + timeout-minutes: 150 + # Known bugs are expected to fail; they're reported, not gating. + continue-on-error: ${{ matrix.lane == 'known-bug' }} + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.5'] + lane: [main, known-bug] + include: + - lane: main + tags: '~@known-bug' + - lane: known-bug + tags: '@known-bug' + steps: + - name: Check out the package + uses: actions/checkout@v7 + with: + path: pkg + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer + extensions: pcntl, curl, sqlite3, zip, dom, mbstring, json + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config -g cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Set up Composer caching + uses: actions/cache@v6 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-php${{ matrix.php }}-${{ github.sha }} + restore-keys: ${{ runner.os }}-composer-php${{ matrix.php }}- + + - name: Prepare the host + run: | + # The runner image ships web and database servers; EE needs 80/443. + sudo systemctl stop nginx apache2 mysql 2>/dev/null || true + sudo ss -ltnp | grep -E ':(80|443|3306) ' || true + sudo curl -fsSL "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker version --format 'docker {{.Server.Version}}' + docker-compose version + openssl version + echo "umask $(umask), under sudo $(sudo sh -c umask)" + if [ -n "$EE_RCLONE_PATH" ]; then + curl -fsSL "https://github.com/rclone/rclone/releases/download/${RCLONE_RELEASE}/rclone-${RCLONE_RELEASE}-linux-amd64.zip" -o "$RUNNER_TEMP/rclone.zip" + unzip -q -j -o "$RUNNER_TEMP/rclone.zip" '*/rclone' -d "$RUNNER_TEMP" + sudo install -m 0755 "$RUNNER_TEMP/rclone" /usr/local/bin/rclone + sudo mkdir -p "$(dirname "$RCLONE_CONFIG")" /var/tmp/ee-rclone + printf '[%s]\ntype = alias\nremote = /var/tmp/ee-rclone\n' "${EE_RCLONE_PATH%%:*}" | sudo tee "$RCLONE_CONFIG" >/dev/null + sudo -E rclone listremotes + fi + + - name: Set up EasyEngine core + run: | + git clone -q --depth 1 -b "$CORE_REF" https://github.com/EasyEngine/easyengine.git easyengine + cd easyengine + # All easyengine/* packages on dev-develop (as core CI does), except the one under test: that's this checkout. + php -r ' + $pkg = json_decode(file_get_contents("../pkg/composer.json"), true)["name"]; + $j = json_decode(file_get_contents("composer.json"), true); + foreach ($j["require"] as $k => $v) if (strpos($k, "easyengine/") === 0) $j["require"][$k] = "dev-develop"; + $repos = isset($j["repositories"]) ? $j["repositories"] : []; + array_unshift($repos, ["type" => "path", "url" => "../pkg", "options" => ["symlink" => false, "versions" => [$pkg => "dev-develop"]]]); + $j["repositories"] = $repos; + file_put_contents("composer.json", json_encode($j, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + file_put_contents(getenv("GITHUB_ENV"), "PKG=$pkg\n", FILE_APPEND);' + composer update --prefer-dist --no-progress --no-interaction + composer show "$(jq -r .name ../pkg/composer.json)" | grep -E '^(versions|source|dist) ' || true + rm -rf features + cp -R ../pkg/features features + # Some commands (clone, sync) shell out to `ee` from PATH. + sudo ln -sf "$PWD/bin/ee" /usr/local/bin/ee + + - name: Initialise EasyEngine + working-directory: easyengine + run: | + sudo -E bin/ee cli info + # The first non-cli command runs the initial setup (DB, config, image pulls). + sudo -E bin/ee site list || true + # Non-LE commands such as ssl-renew still prompt for le-mail when it's missing. + sudo -E bin/ee config set le-mail behat@example.com + if [ -n "$EE_RCLONE_PATH" ]; then + sudo -E bin/ee config set rclone-path "$EE_RCLONE_PATH" + sudo -E bin/ee config set no-of-backups 3 + fi + + - name: Run Behat + working-directory: easyengine + shell: 'script -q -e -c "bash {0}"' + run: | + set -e + mkdir -p "$RUNNER_TEMP/behat/junit" + sudo -E vendor/bin/behat --strict --tags="${EXCLUDE_TAGS}&&${{ matrix.tags }}" \ + --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 chown -R "$USER" "$out" + # One block per failed scenario, from the junit files. + python3 - "$out" <<'PY' + import glob, sys, xml.etree.ElementTree as ET + out = sys.argv[1] + lines = [] + for f in sorted(glob.glob(out + '/junit/*.xml')): + for case in ET.parse(f).getroot().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 From 70b696aa888b502645a435b44c0f60ac31c26a4e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 19:30:05 +0530 Subject: [PATCH 13/22] ci: finalize the behat workflow Run PHP 7.4 and 8.5, strip control characters from the junit files before building failures.txt, and cap the Behat step at 75 minutes so a hung run still uploads its logs. Diagnostics: the host addresses and routes, and the docker-compose version after the first run (MIG-20). --- .github/workflows/behat.yml | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index 0544149..f3bec34 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -22,7 +22,7 @@ env: RCLONE_RELEASE: v1.75.1 # No public DNS (Let's Encrypt) or real backup storage on hosted runners. EXCLUDE_TAGS: '~@le&&~@remote-real' - # Local rclone alias written to EE's rclone-path; empty = no rclone. + # Local rclone alias written to EE's rclone-path, for suites with @remote scenarios ('eetest:ee-backups'); empty = no rclone. EE_RCLONE_PATH: '' COMPOSE_INTERACTIVE_NO_CLI: 1 RCLONE_CONFIG: /etc/rclone/rclone.conf @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 150 # Known bugs are expected to fail; they're reported, not gating. - continue-on-error: ${{ matrix.lane == 'known-bug' }} + continue-on-error: ${{ endsWith(matrix.lane, 'known-bug') }} strategy: fail-fast: false matrix: @@ -81,6 +81,9 @@ jobs: docker-compose version openssl version echo "umask $(umask), under sudo $(sudo sh -c umask)" + # Suites that request the host's public IP use the first of these; EE picks a frontend subnet outside eth0's. + echo "hostname -I: $(hostname -I)" + ip -4 route if [ -n "$EE_RCLONE_PATH" ]; then curl -fsSL "https://github.com/rclone/rclone/releases/download/${RCLONE_RELEASE}/rclone-${RCLONE_RELEASE}-linux-amd64.zip" -o "$RUNNER_TEMP/rclone.zip" unzip -q -j -o "$RUNNER_TEMP/rclone.zip" '*/rclone' -d "$RUNNER_TEMP" @@ -118,22 +121,29 @@ jobs: # The first non-cli command runs the initial setup (DB, config, image pulls). sudo -E bin/ee site list || true # Non-LE commands such as ssl-renew still prompt for le-mail when it's missing. - sudo -E bin/ee config set le-mail behat@example.com + sudo -E bin/ee config set le-mail behat-ci@example.org if [ -n "$EE_RCLONE_PATH" ]; then sudo -E bin/ee config set rclone-path "$EE_RCLONE_PATH" sudo -E bin/ee config set no-of-backups 3 fi + # The first run swaps the installed compose for 2.27.0 (MIG-20), so that's what the suite runs on, as on a fresh install. + docker-compose version + - name: Run Behat working-directory: easyengine shell: 'script -q -e -c "bash {0}"' + # A hung step (e.g. a command stopped on the TTY) should still leave logs. + timeout-minutes: 75 run: | set -e mkdir -p "$RUNNER_TEMP/behat/junit" - sudo -E vendor/bin/behat --strict --tags="${EXCLUDE_TAGS}&&${{ matrix.tags }}" \ - --format=progress --out=std \ - --format=junit --out="$RUNNER_TEMP/behat/junit" \ - --format=pretty --out="$RUNNER_TEMP/behat/pretty.log" + base="$EXCLUDE_TAGS" + behat=(sudo -E vendor/bin/behat --strict --tags="${base}&&${{ matrix.tags }}" + --format=progress --out=std + --format=junit --out="$RUNNER_TEMP/behat/junit" + --format=pretty --out="$RUNNER_TEMP/behat/pretty.log") + "${behat[@]}" - name: Collect logs if: always() @@ -146,11 +156,13 @@ jobs: sudo chown -R "$USER" "$out" # One block per failed scenario, from the junit files. python3 - "$out" <<'PY' - import glob, sys, xml.etree.ElementTree as ET + import glob, re, sys, xml.etree.ElementTree as ET out = sys.argv[1] lines = [] for f in sorted(glob.glob(out + '/junit/*.xml')): - for case in ET.parse(f).getroot().iter('testcase'): + # 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') @@ -158,6 +170,7 @@ jobs: PY head -c 20000 "$out/failures.txt" + - name: Upload results if: always() uses: actions/upload-artifact@v7 From 4c15882064f940cd49246c0edb633e13fcf62035 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 19:33:29 +0530 Subject: [PATCH 14/22] ci: re-fetch docker-compose when the first run leaves a broken binary EE's first run downloads docker-compose 2.27.0 without --fail (MIG-10); on a GitHub error it moves the HTML error page over the binary, as on an admin-tools-command runner. Fetch 2.27.0 again with --fail and retries in that case. --- .github/workflows/behat.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index f3bec34..a7ae6f5 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -127,7 +127,12 @@ jobs: sudo -E bin/ee config set no-of-backups 3 fi # The first run swaps the installed compose for 2.27.0 (MIG-20), so that's what the suite runs on, as on a fresh install. - docker-compose version + # Its download has no --fail (MIG-10): a GitHub error page can replace the binary, so fetch 2.27.0 again then. + if ! docker-compose version; then + sudo curl -fsSL --retry 5 "https://github.com/docker/compose/releases/download/v2.27.0/docker-compose-linux-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose version + fi - name: Run Behat From 1141a039441c68cf5c99d23ecca48763d1ce412c Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 19 Oct 2026 19:38:23 +0530 Subject: [PATCH 15/22] test(behat): leave the known-bug scenarios out until their fixes land The 7 scenario rows written for open bugs assert the fixed behaviour and fail today, so they stay out of the suite until their fixes land, and COVERAGE.md lists those bugs as not covered yet. Every scenario that ships now gates, and the non-gating known-bug CI lane goes with them. --- .github/workflows/behat.yml | 6 +----- features/COVERAGE.md | 22 +++++++++---------- features/log-global.feature | 19 ---------------- features/log-site.feature | 43 ------------------------------------- features/log.feature | 11 ---------- 5 files changed, 12 insertions(+), 89 deletions(-) diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index a7ae6f5..6ade07b 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -33,18 +33,14 @@ jobs: name: Behat ${{ matrix.lane }} (PHP ${{ matrix.php }}) runs-on: ubuntu-latest timeout-minutes: 150 - # Known bugs are expected to fail; they're reported, not gating. - continue-on-error: ${{ endsWith(matrix.lane, 'known-bug') }} strategy: fail-fast: false matrix: php: ['7.4', '8.5'] - lane: [main, known-bug] + lane: [main] include: - lane: main tags: '~@known-bug' - - lane: known-bug - tags: '@known-bug' steps: - name: Check out the package uses: actions/checkout@v7 diff --git a/features/COVERAGE.md b/features/COVERAGE.md index 632b088..df22bc2 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -21,7 +21,7 @@ The package registers one command, `ee log`, with one subcommand, `show` (there | Wrong name inside a site dir | "…is not a valid site-name. Did you mean…" | log.feature "A wrong site name inside a site directory…" | | Disabled site | `get_site_info()` "is not enabled" error | log.feature "A disabled site is refused" | | Trailing slash in site name | Accepted | log.feature "A trailing slash…" | -| `http:///` form | Should resolve; fails (LOGCMD-8) | log.feature `@known-bug` "A site given as a URL…" | +| `http:///` form | Should resolve; fails (LOGCMD-8) | not covered yet: LOGCMD-8 (its scenario comes back with the fix) | | Default types (nginx, php, wp) on html | nginx `access.log` + `error.log` only | log-site "html site default…" | | Default types on php | nginx + php logs, no wp warning | log-site "php site default…" | | Default types on wp | nginx + php logs, "wp log doesn't exist!" warning when no debug.log | log-site "wp site default…" | @@ -33,9 +33,9 @@ The package registers one command, `ee log`, with one subcommand, `show` (there | `--all` | All five types | log-site "--all on a wp site…" | | Combined type flags | Union, de-duplicated (`array_unique`) | log-site "Combined type flags…", "--nginx --php on a php site…" | | Type the site lacks | "No logs found!", exit 1 | log-site outline "A log type the site does not have…" (html `--php`, html `--wp`, php `--wp`) | -| html/php sites drop the wp type | Intended by `unset()`, broken (LOGCMD-3) | log-site `@known-bug` outline "--wp is ignored for non-WordPress sites…" | +| html/php sites drop the wp type | Intended by `unset()`, broken (LOGCMD-3) | not covered yet: LOGCMD-3 (its scenario comes back with the fix) | | Non-`.log` files and rotated `*.log.1` | Ignored | log-site "Files without a .log extension…" | -| File without extension | PHP warning (LOGCMD-7) | log-site `@known-bug` "A file without an extension…" | +| File without extension | PHP warning (LOGCMD-7) | not covered yet: LOGCMD-7 (its scenario comes back with the fix) | | `--n` default 10 | `tail -n 10` | log-site "The default line count is 10" | | `--n=` / `--n=0` | Initial lines limited / none | log-site "--n limits…", "--n=0…", log-global "--cli honours --n" (exactly one ee.log line) | | `--n` without value | Framework warning, falls back to 10 | log-site "--n without a value…" | @@ -44,7 +44,7 @@ The package registers one command, `ee log`, with one subcommand, `show` (there | Following (`tail -f`) | New lines stream until killed | log-site "New lines are streamed while following"; every happy path asserts exit 124 (still running at the timeout) | | Real nginx access log entry | HTTP request appears in `--access` output | log-site "A real request shows up in the nginx access log" | | Links in a site log directory | Followed only if the real path is a regular file inside the log directory (the app volume for `debug.log`), otherwise skipped with a warning (LOGCMD-9, fixed on `fix/log-symlink-escape`) | log-site outline "A log link that leaves the site's log directory is skipped" (php `logs/php`, html nested `logs/nginx`, wp `debug.log`), "A log link inside the site's log directory is still followed" | -| "watching logfile" lines | `EE::log`, path shown relative to the sites dir; should be hidden by `--quiet`, isn't (core CORE-3) | all happy paths; log-site `@known-bug` "--quiet hides…" | +| "watching logfile" lines | `EE::log`, path shown relative to the sites dir; should be hidden by `--quiet`, isn't (core CORE-3) | all happy paths; `--quiet` not covered yet: CORE-3 (its scenario comes back with the fix) | | `--cli` | Follows `{EE_ROOT_DIR}/logs/ee.log`; the "watching logfile" line is also written to ee.log (side effect) | log-global "--cli follows EasyEngine's own log" | | `--cli` with a site name / type flags | Both ignored | log-global "--cli ignores a site name and type flags" | | `--global` | ee.log + nginx/php/wp logs of every site | log-global "--global follows ee.log and the logs of every site type" | @@ -52,18 +52,18 @@ The package registers one command, `ee log`, with one subcommand, `show` (there | `--global` with `--cli` and a (nonexistent) site name | `--global` wins, site name ignored (documents current behaviour) | log-global "--global takes precedence…" | | `--global` and disabled sites | Included | log-global "--global includes disabled sites" | | `--global` undefined `$wp_logs_path` | No PHP warning (LOGCMD-4, fixed on `fix/log-symlink-escape`) | log-global "--global raises no PHP warnings…" | -| `--global` with type filters | Filters ignored (LOGCMD-5) | log-global `@known-bug` "--global honours a type filter" | -| Service logs (help promises them for `--global`/`--all`) | Never followed (LOGCMD-6) | log-global `@known-bug` "--global includes the global nginx-proxy logs" | +| `--global` with type filters | Filters ignored (LOGCMD-5) | not covered yet: LOGCMD-5 (its scenario comes back with the fix) | +| Service logs (help promises them for `--global`/`--all`) | Never followed (LOGCMD-6) | not covered yet: LOGCMD-6 (its scenario comes back with the fix) | | `--cli`/`--global` "cli log doesn't exist!" and global "No logs found!" | Not covered: unreachable, ee's file logger creates ee.log before the command runs | – | | Local-DB mariadb logs (`services/mariadb/logs`) | Not covered: never read by the command (part of LOGCMD-6), and a local-DB site adds ~1 min | – | Side effects: the command is read-only apart from appending its "watching logfile" lines to ee.log (asserted). There are no DB rows, containers, proxy config or hooks to verify; idempotency is implicit (every scenario can be rerun; site state is asserted unchanged for the disabled site). -Estimated coverage: about 95% of the branches of `Log_Command` (every branch except the unreachable missing-ee.log paths). +Estimated coverage: about 95% of the branches of `Log_Command` (every branch except the unreachable missing-ee.log paths) once the held-back known-bug scenarios are back; until then the LOGCMD-3, LOGCMD-5, LOGCMD-7 and LOGCMD-8 paths are not reached. -## Known bugs (`## log-command` in issues.md) +## Known bugs (`## log-command` in issues.md, scenarios held back until fixed) -`@known-bug` scenarios assert the fixed behaviour, so they fail until the bug is fixed. +The scenarios for the open bugs here (LOGCMD-3, LOGCMD-5 to LOGCMD-8, CORE-3) assert the fixed behaviour, so they are not in the suite yet and return as regular scenarios with their fixes. - LOGCMD-1 (High): `--n` and the file list were interpolated unescaped into `system( "tail -n $lines -f $final_string" )`. `logs/php` is writable by www-data in the php container, so a planted file name like `x$(cmd).log` ran as root on the host when the admin followed the logs. Fixed on `fix/escape-log-tail` (not merged yet); its scenarios are no longer `@known-bug`, so they fail against a develop without the fix. - LOGCMD-2 (Low): `--n` was not validated and tail's exit status was ignored, so invalid values or tail failures exited 0. Fixed with LOGCMD-1. @@ -78,8 +78,8 @@ Estimated coverage: about 95% of the branches of `Log_Command` (every branch exc ## Tags and runtime -- `@known-bug`: 7 scenarios (6 definitions, one outline with 2 examples). Exclude with `--tags='~@known-bug'`. +- No `@known-bug` scenarios ship here: the 7 scenario rows written for open bugs (LOGCMD-3 with 2 rows, LOGCMD-5, LOGCMD-6, LOGCMD-7, LOGCMD-8 and CORE-3) are held back until their fixes land and then return as regular scenarios. - No `@le`, `@remote` or `@destructive` scenarios: the command needs no public DNS or backups and changes no global state. - Sites (prefix `bh-log-command-`, deleted by `@AfterFeature`, which only touches that prefix): `-1` html, `-2` php, `-3` wp, `-4` html (disabled), `-99` guaranteed absent. Sites are created lazily by `Given the site … of type … exists`, so each scenario runs on its own and only the first scenario of a feature pays for creation. - Every `ee log show` run is wrapped in `timeout` (own process group, so `tail` dies too); exit 124 means "still following". -- Estimated runtime: about 12 minutes (site creation ~4 minutes over the three features, WordPress dominating; 59 scenarios at 3–10 s each). +- Estimated runtime: about 12 minutes (site creation ~4 minutes over the three features, WordPress dominating; 52 scenarios at 3–10 s each). diff --git a/features/log-global.feature b/features/log-global.feature index eec90f4..178e169 100644 --- a/features/log-global.feature +++ b/features/log-global.feature @@ -2,7 +2,6 @@ Feature: ee log show --cli and --global # --cli follows {EE_ROOT_DIR}/logs/ee.log; --global follows ee.log plus every site's logs. # These scenarios only read ee.log; the "watching logfile" lines ee itself writes there are used as markers. -# @known-bug scenarios assert the fixed behaviour, so they fail until the referenced issues.md entry is fixed. # The html site is created first on purpose: LOGCMD-4 only shows when a non-WordPress site precedes the first WordPress site. Scenario: --cli follows EasyEngine's own log @@ -84,21 +83,3 @@ Feature: ee log show --cli and --global When I run "bin/ee log show --global" with a timeout of 10 seconds Then STDERR should not contain "wp_logs_path" And the command should still be running when the timeout expires - - # LOGCMD-5: --global computes the requested types but never uses them, so type filters are silently ignored. - @known-bug - Scenario: --global honours a type filter - Given the site "bh-log-command-2.test" of type "php" exists - When I run "bin/ee log show --global --nginx" with a timeout of 10 seconds - Then STDOUT should contain "watching logfile bh-log-command-2.test/logs/nginx/access.log" - And STDOUT should not contain "watching logfile bh-log-command-2.test/logs/php/" - And the command should still be running when the timeout expires - - # LOGCMD-6: the help says --global covers "all services" (and --all "service logs"), - # but no service log (e.g. nginx-proxy under {EE_ROOT_DIR}/services/nginx-proxy/logs) is ever followed. - @known-bug - Scenario: --global includes the global nginx-proxy logs - Given the file "{EE_ROOT_DIR}/services/nginx-proxy/logs/access.log" should exist - When I run "bin/ee log show --global" with a timeout of 10 seconds - Then STDOUT should contain "watching logfile {EE_ROOT_DIR}/services/nginx-proxy/logs/access.log" - And the command should still be running when the timeout expires diff --git a/features/log-site.feature b/features/log-site.feature index f384bb2..77a5574 100644 --- a/features/log-site.feature +++ b/features/log-site.feature @@ -3,7 +3,6 @@ Feature: ee log show for a single site (type filters, line count, following) # Sites: bh-log-command-1.test (html), bh-log-command-2.test (php), bh-log-command-3.test (wp). # `ee log show` ends in `tail -f`, so runs are bounded by a timeout and exit 124 means "still following". # Extra *.log files written by scenarios live inside the site's own log volume and are removed after each scenario. -# @known-bug scenarios assert the fixed behaviour, so they fail until the referenced issues.md entry is fixed. Scenario: html site default shows only the nginx logs Given the site "bh-log-command-1.test" of type "html" exists @@ -191,39 +190,6 @@ Feature: ee log show for a single site (type filters, line count, following) And STDOUT should not contain "bh-rotated" And the command should still be running when the timeout expires - # LOGCMD-7: get_files() reads pathinfo()['extension'] without isset(), so a file without - # an extension in a log directory raises "Undefined array key" (PHP 8) / "Undefined index" (PHP 7.4). - @known-bug - Scenario: A file without an extension in a log directory does not raise PHP warnings - Given the site "bh-log-command-1.test" of type "html" exists - And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-noext" contains: - """ - bh-noext-marker - """ - When I run "bin/ee log show bh-log-command-1.test --nginx" with a timeout of 8 seconds - Then STDERR should not contain "Undefined" - And STDOUT should contain "watching logfile" exactly 2 times - And the command should still be running when the timeout expires - - # LOGCMD-3: show() does unset( $final_types['php'], $final_types['wp'] ) on a list with integer - # keys, so the wp type is never dropped for html/php sites and a wp-content/debug.log there gets followed. - @known-bug - Scenario Outline: --wp is ignored for non-WordPress sites even when a debug.log exists - Given the site "" of type "" exists - And the file "{SITES_DIR}//app/htdocs/wp-content/debug.log" contains: - """ - bh-non-wp-debug-marker - """ - When I run "bin/ee log show --wp" with a timeout of 20 seconds - Then STDOUT should not contain "wp-content/debug.log" - And STDERR should contain "Error: No logs found!" - And the command should exit with code 1 - - Examples: - | site | type | - | bh-log-command-1.test | html | - | bh-log-command-2.test | php | - Scenario: The default line count is 10 Given the site "bh-log-command-1.test" of type "html" exists And the file "{SITES_DIR}/bh-log-command-1.test/logs/nginx/bh-lines.log" contains: @@ -377,12 +343,3 @@ Feature: ee log show for a single site (type filters, line count, following) When I run "bin/ee log show bh-log-command-1.test --access --n=50" with a timeout of 8 seconds Then STDOUT should contain "GET /bh-log-command-access-probe" And the command should still be running when the timeout expires - - # CORE-3: the logger is chosen before the config is parsed, so --quiet never takes effect. - @known-bug - Scenario: --quiet hides the "watching logfile" lines but still follows - Given the site "bh-log-command-1.test" of type "html" exists - When I run "bin/ee log show bh-log-command-1.test --nginx --quiet" with a timeout of 8 seconds - Then STDOUT should not contain "watching logfile" - And STDOUT should contain "==> {SITES_DIR}/bh-log-command-1.test/logs/nginx/access.log <==" - And the command should still be running when the timeout expires diff --git a/features/log.feature b/features/log.feature index 9822cb8..dd8dad1 100644 --- a/features/log.feature +++ b/features/log.feature @@ -2,7 +2,6 @@ Feature: ee log command surface, site resolution and validation errors # `ee log show` ends in `tail -f`, so every invocation that can reach tail runs under a timeout; # exit code 124 ("still running when the timeout expired") is the success signal for following. -# @known-bug scenarios assert the fixed behaviour, so they fail until the referenced issues.md entry is fixed. Scenario: ee log without a subcommand lists show When I run 'bin/ee log' @@ -81,13 +80,3 @@ Feature: ee log command surface, site resolution and validation errors When I run "bin/ee log show bh-log-command-1.test/" with a timeout of 8 seconds Then STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" And the command should still be running when the timeout expires - - # LOGCMD-8: auto_site_name() accepts "http://" but returns the args unchanged, - # so get_site_info() looks up the raw URL and fails. - @known-bug - Scenario: A site given as a URL is resolved like the bare name - Given the site "bh-log-command-1.test" of type "html" exists - When I run "bin/ee log show http://bh-log-command-1.test/" with a timeout of 8 seconds - Then STDERR should not contain "does not exist" - And STDOUT should contain "watching logfile bh-log-command-1.test/logs/nginx/access.log" - And the command should still be running when the timeout expires From 191668eeeb5b8d15e242eb1a0c4c7e35f9afa1ad Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 01:53:13 +0530 Subject: [PATCH 16/22] test(behat): only remove the phar files this run built The AfterFeature cleanup deleted ee.phar and ee-old.phar from the core checkout after every feature, although no scenario here builds them, so a local run removed a developer's own builds. The 'ee phar is generated' step now records that it built them, and the cleanup only removes them then. --- features/bootstrap/FeatureContext.php | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 806477c..bfca6ac 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -66,6 +66,13 @@ class FeatureContext implements Context { + /** + * Whether this run built ee.phar/ee-old.phar, so the cleanup only removes its own files. + * + * @var bool + */ + private static $phar_built = false; + public $command; public $commands; public $webroot_path; @@ -96,6 +103,7 @@ public function eePharIsPresent() // which updates ee.phar with root privileges. exec("sudo rm ee.phar"); copy('ee-old.phar','ee.phar'); + self::$phar_built = true; return 0; } exec("php -dphar.readonly=0 utils/make-phar.php ee.phar", $output, $return_status); @@ -105,6 +113,7 @@ public function eePharIsPresent() // Cache generaed phar as it is expensive to generate one copy('ee.phar','ee-old.phar'); + self::$phar_built = true; } /** @@ -419,10 +428,13 @@ public static function cleanup( AfterFeatureScope $scope ) EE::launch( 'bin/ee site delete ' . escapeshellarg( $site ) . ' --yes', false, true ); } - foreach ( [ 'ee.phar', 'ee-old.phar' ] as $phar ) { - if ( file_exists( $phar ) ) { - unlink( $phar ); + if ( self::$phar_built ) { + foreach ( [ 'ee.phar', 'ee-old.phar' ] as $phar ) { + if ( file_exists( $phar ) ) { + unlink( $phar ); + } } + self::$phar_built = false; } } From 6cc8208a3e9d8de15a7954b4b904b87d8c563614 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 01:53:22 +0530 Subject: [PATCH 17/22] test(log): quote the bin/ee path when running from another directory The checkout path was spliced into the command unquoted (and as a replacement string), so a path with spaces or a backslash broke the current-directory scenarios. --- features/bootstrap/FeatureContext.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index bfca6ac..d634d58 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -778,7 +778,14 @@ public function iRunFromWithATimeoutOfSeconds( $command, $dir, $seconds ) if ( ! is_dir( $dir ) ) { throw new Exception( "Directory $dir does not exist." ); } - $command = preg_replace( '#(^|\s)bin/ee(?=\s|$)#', '$1' . $this->ee_path . '/bin/ee', $command ); + $ee = escapeshellarg( $this->ee_path . '/bin/ee' ); + $command = preg_replace_callback( + '#(^|\s)bin/ee(?=\s|$)#', + function ( $m ) use ( $ee ) { + return $m[1] . $ee; + }, + $command + ); $this->commands[] = $this->launchWithTimeout( $command, $seconds, $dir ); } From f56b1ea066b146be206032764fcc1b7f2d998359 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 02:25:56 +0530 Subject: [PATCH 18/22] test(behat): fix the container count step and drop the unused subsite step Neither step is used yet. The count step compared a string with an int and threw on a match; the subsite step swapped the site and the subsite and still used the pre-compose-v2 container name. --- features/bootstrap/FeatureContext.php | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index d634d58..0ed6c11 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -135,16 +135,6 @@ public function iRun($command) $this->commands[] = EE::launch($command, false, true); } - /** - * @When I create subsite :subsite in :site - */ - public function iCreateSubsiteInOfType($subsite, $site ) - { - $php_container = implode( explode( '.', $subsite ) ) . '_php_1'; - - EE::launch( "docker exec -it --user='www-data' $php_container sh -c 'wp site create --slug=$site'", false, true ); - } - /** * @Then return value should be 0 */ @@ -412,7 +402,7 @@ public function thereShouldBeContainersWithLabel($expected_running_containers, P $result = EE::launch( "docker ps -qf label=$label_string | wc -l", false, true ); $running_containers = (int) trim($result->stdout); - if($expected_running_containers === $running_containers) { + if ( (int) $expected_running_containers !== $running_containers ) { throw new Exception("Expected $expected_running_containers running containers. Found: $running_containers"); } } From 2d6dfda516cfff9e2296cf618963c335ca650495 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 09:20:43 +0530 Subject: [PATCH 19/22] docs(log-command): describe coverage gaps without workspace references --- .github/workflows/behat.yml | 4 +-- features/COVERAGE.md | 50 ++++++++++++++++++------------------- features/log-global.feature | 6 ++--- features/log-site.feature | 8 +++--- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index 6ade07b..2a740be 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -122,8 +122,8 @@ jobs: sudo -E bin/ee config set rclone-path "$EE_RCLONE_PATH" sudo -E bin/ee config set no-of-backups 3 fi - # The first run swaps the installed compose for 2.27.0 (MIG-20), so that's what the suite runs on, as on a fresh install. - # Its download has no --fail (MIG-10): a GitHub error page can replace the binary, so fetch 2.27.0 again then. + # The first run swaps the installed compose for 2.27.0 (a first-run docker-compose check), so that's what the suite runs on, as on a fresh install. + # Its download has no --fail: a GitHub error page can replace the binary, so fetch 2.27.0 again then. if ! docker-compose version; then sudo curl -fsSL --retry 5 "https://github.com/docker/compose/releases/download/v2.27.0/docker-compose-linux-$(uname -m)" -o /usr/local/bin/docker-compose sudo chmod +x /usr/local/bin/docker-compose diff --git a/features/COVERAGE.md b/features/COVERAGE.md index df22bc2..345107f 100644 --- a/features/COVERAGE.md +++ b/features/COVERAGE.md @@ -21,7 +21,7 @@ The package registers one command, `ee log`, with one subcommand, `show` (there | Wrong name inside a site dir | "…is not a valid site-name. Did you mean…" | log.feature "A wrong site name inside a site directory…" | | Disabled site | `get_site_info()` "is not enabled" error | log.feature "A disabled site is refused" | | Trailing slash in site name | Accepted | log.feature "A trailing slash…" | -| `http:///` form | Should resolve; fails (LOGCMD-8) | not covered yet: LOGCMD-8 (its scenario comes back with the fix) | +| `http:///` form | Should resolve; fails (`auto_site_name()` returns the URL form unchanged) | not covered yet (its scenario comes back with the fix) | | Default types (nginx, php, wp) on html | nginx `access.log` + `error.log` only | log-site "html site default…" | | Default types on php | nginx + php logs, no wp warning | log-site "php site default…" | | Default types on wp | nginx + php logs, "wp log doesn't exist!" warning when no debug.log | log-site "wp site default…" | @@ -33,52 +33,52 @@ The package registers one command, `ee log`, with one subcommand, `show` (there | `--all` | All five types | log-site "--all on a wp site…" | | Combined type flags | Union, de-duplicated (`array_unique`) | log-site "Combined type flags…", "--nginx --php on a php site…" | | Type the site lacks | "No logs found!", exit 1 | log-site outline "A log type the site does not have…" (html `--php`, html `--wp`, php `--wp`) | -| html/php sites drop the wp type | Intended by `unset()`, broken (LOGCMD-3) | not covered yet: LOGCMD-3 (its scenario comes back with the fix) | +| html/php sites drop the wp type | Intended by `unset()`, broken (the `unset()` is a no-op) | not covered yet (its scenario comes back with the fix) | | Non-`.log` files and rotated `*.log.1` | Ignored | log-site "Files without a .log extension…" | -| File without extension | PHP warning (LOGCMD-7) | not covered yet: LOGCMD-7 (its scenario comes back with the fix) | +| File without extension | PHP warning (`extension` read without `isset()`) | not covered yet (its scenario comes back with the fix) | | `--n` default 10 | `tail -n 10` | log-site "The default line count is 10" | | `--n=` / `--n=0` | Initial lines limited / none | log-site "--n limits…", "--n=0…", log-global "--cli honours --n" (exactly one ee.log line) | | `--n` without value | Framework warning, falls back to 10 | log-site "--n without a value…" | -| `--n=abc` | Rejected up front, exit 1 (LOGCMD-2, fixed on `fix/escape-log-tail`) | log-site "An invalid --n value…" | -| Shell injection via `--n` or a log file name | tail runs without a shell (LOGCMD-1, fixed on `fix/escape-log-tail`) | log-site "--n is not interpreted by the shell", "A log file name is not interpreted by the shell" | +| `--n=abc` | Rejected up front, exit 1 (fixed in EasyEngine/log-command#8) | log-site "An invalid --n value…" | +| Shell injection via `--n` or a log file name | tail runs without a shell (fixed in EasyEngine/log-command#8) | log-site "--n is not interpreted by the shell", "A log file name is not interpreted by the shell" | | Following (`tail -f`) | New lines stream until killed | log-site "New lines are streamed while following"; every happy path asserts exit 124 (still running at the timeout) | | Real nginx access log entry | HTTP request appears in `--access` output | log-site "A real request shows up in the nginx access log" | -| Links in a site log directory | Followed only if the real path is a regular file inside the log directory (the app volume for `debug.log`), otherwise skipped with a warning (LOGCMD-9, fixed on `fix/log-symlink-escape`) | log-site outline "A log link that leaves the site's log directory is skipped" (php `logs/php`, html nested `logs/nginx`, wp `debug.log`), "A log link inside the site's log directory is still followed" | -| "watching logfile" lines | `EE::log`, path shown relative to the sites dir; should be hidden by `--quiet`, isn't (core CORE-3) | all happy paths; `--quiet` not covered yet: CORE-3 (its scenario comes back with the fix) | +| Links in a site log directory | Followed only if the real path is a regular file inside the log directory (the app volume for `debug.log`), otherwise skipped with a warning (fixed in EasyEngine/log-command#8) | log-site outline "A log link that leaves the site's log directory is skipped" (php `logs/php`, html nested `logs/nginx`, wp `debug.log`), "A log link inside the site's log directory is still followed" | +| "watching logfile" lines | `EE::log`, path shown relative to the sites dir; should be hidden by `--quiet`, isn't (core sets up the logger before parsing `--quiet`) | all happy paths; `--quiet` not covered yet (its scenario comes back with the fix) | | `--cli` | Follows `{EE_ROOT_DIR}/logs/ee.log`; the "watching logfile" line is also written to ee.log (side effect) | log-global "--cli follows EasyEngine's own log" | | `--cli` with a site name / type flags | Both ignored | log-global "--cli ignores a site name and type flags" | | `--global` | ee.log + nginx/php/wp logs of every site | log-global "--global follows ee.log and the logs of every site type" | | `--global`, WP site without debug.log | Warning, keeps following | log-global "--global warns for a WordPress site…" | | `--global` with `--cli` and a (nonexistent) site name | `--global` wins, site name ignored (documents current behaviour) | log-global "--global takes precedence…" | | `--global` and disabled sites | Included | log-global "--global includes disabled sites" | -| `--global` undefined `$wp_logs_path` | No PHP warning (LOGCMD-4, fixed on `fix/log-symlink-escape`) | log-global "--global raises no PHP warnings…" | -| `--global` with type filters | Filters ignored (LOGCMD-5) | not covered yet: LOGCMD-5 (its scenario comes back with the fix) | -| Service logs (help promises them for `--global`/`--all`) | Never followed (LOGCMD-6) | not covered yet: LOGCMD-6 (its scenario comes back with the fix) | +| `--global` undefined `$wp_logs_path` | No PHP warning (fixed in EasyEngine/log-command#8) | log-global "--global raises no PHP warnings…" | +| `--global` with type filters | Filters ignored | not covered yet (its scenario comes back with the fix) | +| Service logs (help promises them for `--global`/`--all`) | Never followed | not covered yet (its scenario comes back with the fix) | | `--cli`/`--global` "cli log doesn't exist!" and global "No logs found!" | Not covered: unreachable, ee's file logger creates ee.log before the command runs | – | -| Local-DB mariadb logs (`services/mariadb/logs`) | Not covered: never read by the command (part of LOGCMD-6), and a local-DB site adds ~1 min | – | +| Local-DB mariadb logs (`services/mariadb/logs`) | Not covered: never read by the command (part of the missing service logs below), and a local-DB site adds ~1 min | – | Side effects: the command is read-only apart from appending its "watching logfile" lines to ee.log (asserted). There are no DB rows, containers, proxy config or hooks to verify; idempotency is implicit (every scenario can be rerun; site state is asserted unchanged for the disabled site). -Estimated coverage: about 95% of the branches of `Log_Command` (every branch except the unreachable missing-ee.log paths) once the held-back known-bug scenarios are back; until then the LOGCMD-3, LOGCMD-5, LOGCMD-7 and LOGCMD-8 paths are not reached. +Estimated coverage: about 95% of the branches of `Log_Command` (every branch except the unreachable missing-ee.log paths) once the held-back known-bug scenarios are back; until then the paths of the html/php type drop, `--global` type filters, files without extension and the `http://` site form are not reached. -## Known bugs (`## log-command` in issues.md, scenarios held back until fixed) +## Known bugs (scenarios held back until fixed) -The scenarios for the open bugs here (LOGCMD-3, LOGCMD-5 to LOGCMD-8, CORE-3) assert the fixed behaviour, so they are not in the suite yet and return as regular scenarios with their fixes. +The scenarios for the open bugs below assert the fixed behaviour, so they are not in the suite yet and return as regular scenarios with their fixes. The fixed ones are covered by regular scenarios. -- LOGCMD-1 (High): `--n` and the file list were interpolated unescaped into `system( "tail -n $lines -f $final_string" )`. `logs/php` is writable by www-data in the php container, so a planted file name like `x$(cmd).log` ran as root on the host when the admin followed the logs. Fixed on `fix/escape-log-tail` (not merged yet); its scenarios are no longer `@known-bug`, so they fail against a develop without the fix. -- LOGCMD-2 (Low): `--n` was not validated and tail's exit status was ignored, so invalid values or tail failures exited 0. Fixed with LOGCMD-1. -- LOGCMD-3 (Low): `unset( $final_types['php'], $final_types['wp'] )` targets string keys of a list, so it is a no-op; html/php sites follow a `wp-content/debug.log` (and an html site would follow `logs/php`). -- LOGCMD-4 (Low): `--global`: `$wp_logs_path` is only set for WP sites, so a non-WP site before the first WP site raises an undefined-variable warning and later ones reuse a stale path. Fixed on `fix/log-symlink-escape` (with LOGCMD-9, on top of `fix/escape-log-tail`); its scenario is no longer `@known-bug`. -- LOGCMD-5 (Low): `--global` ignores `--nginx/--php/--wp/--access/--error` (`$final_types` unused), and it also probes nonexistent `logs/access` and `logs/error` directories. -- LOGCMD-6 (Low): help says `--global` includes "all services" and `--all` "service logs", but no service logs (nginx-proxy, local mariadb) are followed. The docblock examples also show wrong paths (`~/easyengine/sites/example.com/logs/debug.log`). -- LOGCMD-7 (Low): `pathinfo()['extension']` read without `isset()`: a file without extension in a log dir raises a PHP warning. -- LOGCMD-8 (Low, site-command helper): `auto_site_name()` accepts `http(s):///` but returns the args unchanged, so `get_site_info()` fails with "Site http://… does not exist."; site-command's own subcommands use the same pair. -- LOGCMD-9 (Low): links in a site log directory (writable by the site's containers) were followed by the root `tail` on the host. Fixed on `fix/log-symlink-escape`; covered by regular scenarios that fail on a develop without the fix. -- CORE-3 (Low, core): the logger is set up before the config and arguments are parsed, so `--quiet` never hides the "watching logfile" lines (found on the CI runner, https://github.com/mrrobot47/log-command/actions/runs/36085159478). +- Fixed (High): `--n` and the file list were interpolated unescaped into `system( "tail -n $lines -f $final_string" )`. `logs/php` is writable by www-data in the php container, so a planted file name like `x$(cmd).log` ran as root on the host when the admin followed the logs. Fixed in EasyEngine/log-command#8. +- Fixed (Low): `--n` was not validated and tail's exit status was ignored, so invalid values or tail failures exited 0. Fixed in EasyEngine/log-command#8. +- Open (Low): `unset( $final_types['php'], $final_types['wp'] )` targets string keys of a list, so it is a no-op; html/php sites follow a `wp-content/debug.log` (and an html site would follow `logs/php`). +- Fixed (Low): `--global`: `$wp_logs_path` is only set for WP sites, so a non-WP site before the first WP site raises an undefined-variable warning and later ones reuse a stale path. Fixed in EasyEngine/log-command#8. +- Open (Low): `--global` ignores `--nginx/--php/--wp/--access/--error` (`$final_types` unused), and it also probes nonexistent `logs/access` and `logs/error` directories. +- Open (Low): help says `--global` includes "all services" and `--all` "service logs", but no service logs (nginx-proxy, local mariadb) are followed. The docblock examples also show wrong paths (`~/easyengine/sites/example.com/logs/debug.log`). +- Open (Low): `pathinfo()['extension']` read without `isset()`: a file without extension in a log dir raises a PHP warning. +- Open (Low, site-command helper): `auto_site_name()` accepts `http(s):///` but returns the args unchanged, so `get_site_info()` fails with "Site http://… does not exist."; site-command's own subcommands use the same pair. +- Fixed (Low): links in a site log directory (writable by the site's containers) were followed by the root `tail` on the host. Fixed in EasyEngine/log-command#8. +- Open (Low, core): the logger is set up before the config and arguments are parsed, so `--quiet` never hides the "watching logfile" lines (found on the CI runner, https://github.com/mrrobot47/log-command/actions/runs/36085159478). ## Tags and runtime -- No `@known-bug` scenarios ship here: the 7 scenario rows written for open bugs (LOGCMD-3 with 2 rows, LOGCMD-5, LOGCMD-6, LOGCMD-7, LOGCMD-8 and CORE-3) are held back until their fixes land and then return as regular scenarios. +- No `@known-bug` scenarios ship here: the 7 scenario rows written for the open bugs above (2 for the html/php type drop, 1 each for the others) are held back until their fixes land and then return as regular scenarios. - No `@le`, `@remote` or `@destructive` scenarios: the command needs no public DNS or backups and changes no global state. - Sites (prefix `bh-log-command-`, deleted by `@AfterFeature`, which only touches that prefix): `-1` html, `-2` php, `-3` wp, `-4` html (disabled), `-99` guaranteed absent. Sites are created lazily by `Given the site … of type … exists`, so each scenario runs on its own and only the first scenario of a feature pays for creation. - Every `ee log show` run is wrapped in `timeout` (own process group, so `tail` dies too); exit 124 means "still following". diff --git a/features/log-global.feature b/features/log-global.feature index 178e169..6a2c145 100644 --- a/features/log-global.feature +++ b/features/log-global.feature @@ -2,7 +2,7 @@ Feature: ee log show --cli and --global # --cli follows {EE_ROOT_DIR}/logs/ee.log; --global follows ee.log plus every site's logs. # These scenarios only read ee.log; the "watching logfile" lines ee itself writes there are used as markers. - # The html site is created first on purpose: LOGCMD-4 only shows when a non-WordPress site precedes the first WordPress site. + # The html site is created first on purpose: the undefined `$wp_logs_path` bug only shows when a non-WordPress site precedes the first WordPress site. Scenario: --cli follows EasyEngine's own log Given the file "{EE_ROOT_DIR}/logs/ee.log" should exist @@ -74,9 +74,9 @@ Feature: ee log show --cli and --global And the command should still be running when the timeout expires And the site "bh-log-command-4.test" should be disabled - # LOGCMD-4: in the --global loop $wp_logs_path is only assigned for WordPress sites, so a non-WordPress site + # Regression check (fixed in EasyEngine/log-command#8): in the --global loop $wp_logs_path is only assigned for WordPress sites, so a non-WordPress site # before the first WordPress site hits an undefined variable (later ones reuse a stale path). Only exposed while - # the html site's DB row precedes the wp site's (true when the feature runs in order). Fixed with LOGCMD-9. + # the html site's DB row precedes the wp site's (true when the feature runs in order). Scenario: --global raises no PHP warnings for non-WordPress sites Given the site "bh-log-command-1.test" of type "html" exists And the site "bh-log-command-3.test" of type "wp" exists diff --git a/features/log-site.feature b/features/log-site.feature index 77a5574..791ea69 100644 --- a/features/log-site.feature +++ b/features/log-site.feature @@ -264,7 +264,7 @@ Feature: ee log show for a single site (type filters, line count, following) And STDOUT should not contain "bh-line-02" And the command should still be running when the timeout expires - # LOGCMD-2: --n was never validated and the exit status of tail was ignored, so an invalid + # Regression check (fixed in EasyEngine/log-command#8): --n was never validated and the exit status of tail was ignored, so an invalid # line count printed tail's error but ee still exited 0. Scenario: An invalid --n value fails with a non-zero exit code Given the site "bh-log-command-1.test" of type "html" exists @@ -273,7 +273,7 @@ Feature: ee log show for a single site (type filters, line count, following) And STDOUT should not contain "watching logfile" And the command should exit with a non-zero code - # LOGCMD-1: --n and the file list were interpolated unescaped into system( "tail -n $lines -f ..." ). + # Regression check (fixed in EasyEngine/log-command#8): --n and the file list were interpolated unescaped into system( "tail -n $lines -f ..." ). Scenario: --n is not interpreted by the shell Given the site "bh-log-command-1.test" of type "html" exists When I run "bin/ee log show bh-log-command-1.test --nginx --n='1 -q /dev/null; echo BH_INJ_$((6*7)); #'" with a timeout of 8 seconds @@ -281,7 +281,7 @@ Feature: ee log show for a single site (type filters, line count, following) And STDERR should contain "Invalid --n value" And the command should exit with code 1 - # LOGCMD-1: log file names were interpolated unescaped too; logs/php is writable by www-data in the php + # Regression check (same fix): log file names were interpolated unescaped too; logs/php is writable by www-data in the php # container, so a site's PHP code could plant a name that ran as root on the host when the admin followed the logs. Scenario: A log file name is not interpreted by the shell Given the site "bh-log-command-2.test" of type "php" exists @@ -296,7 +296,7 @@ Feature: ee log show for a single site (type filters, line count, following) And STDOUT should contain "bh-inj-marker" And the command should still be running when the timeout expires - # LOGCMD-9: the site's containers can write to its log directories and wp-content, and ee log show runs tail as + # Regression check (fixed in EasyEngine/log-command#8): the site's containers can write to its log directories and wp-content, and ee log show runs tail as # root on the host, so a link planted there must not be followed out of the directory. Scenario Outline: A log link that leaves the site's log directory is skipped Given the site "" of type "" exists From 640a40c48259a5a35751f7e23298ac063fa85d7e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 09:29:59 +0530 Subject: [PATCH 20/22] ci(log-command): run the behat lanes on every supported PHP version --- .github/workflows/behat.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index 2a740be..7e67dd5 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -36,7 +36,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['7.4', '8.5'] + php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] lane: [main] include: - lane: main From 8e4b8d2709d927c1f1d81f268d1d93ed0870d6c9 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 09:45:36 +0530 Subject: [PATCH 21/22] test(log-command): match lists by membership and compare whole multiline output compareContents() compared list elements by position, so [1,3,5] wasn't found in [1,2,3,4,5], and a missing key or property raised a notice. The indexed exact-output step and both something-like steps only used the first line of the expected text. --- features/bootstrap/FeatureContext.php | 9 +++------ features/bootstrap/support.php | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 0ed6c11..04d947a 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -186,8 +186,7 @@ public function stdoutOfCommandShouldReturnExactly($output_stream, $index, PyStr $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 ( $expected_output->getRaw() !== trim($command_output)) { throw new Exception("Actual output is:\n" . $command_output); } } @@ -199,8 +198,7 @@ public function stdoutShouldReturnSomethingLike($output_stream, PyStringNode $ex { $command_output = $output_stream === "STDOUT" ? $this->commands[0]->stdout : $this->commands[0]->stderr; - $expected_out = isset($expected_output->getStrings()[0]) ? $expected_output->getStrings()[0] : ''; - if (strpos($command_output, $expected_out) === false) { + if (strpos($command_output, $expected_output->getRaw()) === false) { throw new Exception("Actual output is:\n" . $command_output); } } @@ -213,8 +211,7 @@ public function stdoutOfCommandShouldReturnSomethingLike($output_stream, $index, { $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] : ''; - if (strpos($command_output, $expected_out) === false) { + if (strpos($command_output, $expected_output->getRaw()) === false) { throw new Exception("Actual output is:\n" . $command_output); } } diff --git a/features/bootstrap/support.php b/features/bootstrap/support.php index ed9a5b5..a5f1a1e 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; } } From d13a3c7d7e9882698ea64d38613f1c83df59f26e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 20 Oct 2026 09:53:24 +0530 Subject: [PATCH 22/22] test(log-command): count each expected CSV row once --- features/bootstrap/support.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/features/bootstrap/support.php b/features/bootstrap/support.php index a5f1a1e..048f72e 100644 --- a/features/bootstrap/support.php +++ b/features/bootstrap/support.php @@ -190,6 +190,8 @@ function checkThatCsvStringContainsValues( $actualCSV, $expectedCSV ) { $actual_row = array_intersect_key( array_combine( $actualHeaders, $actual_row ), $expected_row ); if ( $actual_row == $expected_row ) { $expectedResult ++; + // One match per expected row, so duplicates can't stand in for a missing row. + break; } } }