diff --git a/.github/workflows/functional.yml b/.github/workflows/functional.yml index bfea7dd..a692850 100644 --- a/.github/workflows/functional.yml +++ b/.github/workflows/functional.yml @@ -45,7 +45,7 @@ jobs: - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: - php-version: "${{ matrix.php-version }}" + php-version: ${{ matrix.php-version }} coverage: xdebug - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 @@ -54,10 +54,10 @@ jobs: - name: Run tests if: ${{ !matrix.experimental }} - # Include --fail-on-skipped when min PHP is > 7.2.5 - run: vendor/bin/phpunit --group functional + # Include --fail-on-skipped when min PHP is > 7.2.5 (for PHPUnit 9.5+) + run: vendor/bin/phpunit --group xdebug-functional # Show deprecations on latest PHP - name: Run tests (experimental) if: ${{ matrix.experimental }} - run: vendor/bin/phpunit --group functional --fail-on-skipped --display-deprecations + run: vendor/bin/phpunit --group xdebug-functional --fail-on-skipped --display-deprecations diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 717aec4..1756508 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,7 +11,7 @@ - functional + xdebug-functional diff --git a/tests/App/Framework/AppHelper.php b/tests/App/Framework/AppHelper.php index b929fab..a80b998 100644 --- a/tests/App/Framework/AppHelper.php +++ b/tests/App/Framework/AppHelper.php @@ -16,6 +16,15 @@ use Composer\XdebugHandler\Process; use Composer\XdebugHandler\XdebugHandler; +/** + * This class runs and provides functionality for the xdebug-handler functional + * test apps. + * + * It sets up the logger and outputs debug and status messages to stdout. + * + * The test apps can be run from the command-line and will show detailed output + * with the --display and --inis options. + */ class AppHelper extends AppRunner { public const ENV_IS_DISPLAY = 'XDEBUG_HANDLER_TEST_DISPLAY'; @@ -23,16 +32,15 @@ class AppHelper extends AppRunner /** @var string */ private $appName; + /** @var string */ private $envPrefix; + /** @var non-empty-list */ private $serverArgv; + /** @var class-string|null */ private $className; - /** @var bool */ - private $display; - /** @var bool */ - private $showInis; /** @var Logger */ private $logger; @@ -41,7 +49,7 @@ class AppHelper extends AppRunner private $output; /** @var Status */ - private $status = null; + private $status; public function __construct(string $script) { @@ -49,13 +57,11 @@ public function __construct(string $script) $this->appName = $this->getAppName($script); $this->envPrefix = $this->getEnvPrefix($script); - $this->display = $this->getDisplayFromServer(); - $this->showInis = $this->getInisFromServer(); - $this->setServerArgv(); + [$display, $showInis] = $this->setServerArgvAndOptions(); - $this->output = new Output($this->display); + $this->output = new Output($display); $this->logger = new Logger($this->output); - $this->status = new Status($this->display, $this->showInis); + $this->status = new Status($display, $showInis); } /** @@ -73,9 +79,10 @@ public function getServerArgv0(): string } /** + * Starts an instance of XdebugHandler * - * @param class-string $class - * @param array $settings + * @param class-string $class Extending class name + * @param array $settings XdebugHandler settings */ public function getXdebugHandler(?string $class = null, ?array $settings = null): XdebugHandler { @@ -102,16 +109,40 @@ public function getXdebugHandler(?string $class = null, ?array $settings = null) return $xdebug; } + /** + * Runs a PHP sub-process + * + * @param string $script The file to run + * @param PhpOptions $options + */ public function runScript(string $script, ?PhpOptions $options = null): void { - parent::runScript($script, $options); + $script = $this->checkScript($script); + + if ($options === null) { + $options = new PhpOptions(); + } + + // set passthru in child proccesses so output gets collected + $options->setPassthru(true); + $this->phpExecutor->run($script, $options, $this->scriptDir); } + /** + * Outputs a message prefixed appname[pid] to stdout + * + * @param string $message + */ public function write(string $message): void { $this->logger->write($message, $this->appName); } + /** + * Outputs the Xdebug loaded status to stdout + * + * More details can be shown with the --display and --inis options. + */ public function writeXdebugStatus(): void { $className = $this->className ?? XdebugHandler::class; @@ -122,10 +153,20 @@ public function writeXdebugStatus(): void } } - private function setServerArgv(): void + /** + * Checks $_SERVER['argv'] and returns $display and $showInis values + * + * The flags to display the full status and ini file list are passed on the + * command-line, then removed and set in the environment. + * + * @return array + */ + private function setServerArgvAndOptions(): array { $args = []; $errors = false; + $display = false; + $showInis = false; if (isset($_SERVER['argv']) && is_array($_SERVER['argv'])) { foreach ($_SERVER['argv'] as $value) { @@ -134,6 +175,18 @@ private function setServerArgv(): void break; } + if ($value === '--display') { + $display = true; + Process::setEnv(self::ENV_IS_DISPLAY, '1'); + continue; + } + + if ($value === '--inis') { + $showInis = true; + Process::setEnv(self::ENV_SHOW_INIS, '1'); + continue; + } + $args[] = $value; } } @@ -142,46 +195,12 @@ private function setServerArgv(): void throw new \RuntimeException('$_SERVER[argv] is not as expected'); } + $_SERVER['argv'] = $args; $this->serverArgv = $args; - } - - private function getDisplayFromServer(): bool - { - $result = false; - - if (isset($_SERVER['argv']) && is_array($_SERVER['argv'])) { - $key = array_search('--display', $_SERVER['argv'], true); - - if (is_int($key)) { - $result = true; - Process::setEnv(self::ENV_IS_DISPLAY, '1'); - // @phpstan-ignore argument.type - array_splice($_SERVER['argv'], $key, 1); - } else { - $result = false !== getenv(self::ENV_IS_DISPLAY); - } - } - - return $result; - } - - private function getInisFromServer(): bool - { - $result = false; - - if (isset($_SERVER['argv']) && is_array($_SERVER['argv'])) { - $key = array_search('--inis', $_SERVER['argv'], true); - - if (is_int($key)) { - $result = true; - Process::setEnv(self::ENV_SHOW_INIS, '1'); - // @phpstan-ignore argument.type - array_splice($_SERVER['argv'], $key, 1); - } else { - $result = false !== getenv(self::ENV_SHOW_INIS); - } - } - return $result; + return [ + $display || false !== getenv(self::ENV_IS_DISPLAY), + $showInis || false !== getenv(self::ENV_SHOW_INIS), + ]; } } diff --git a/tests/App/Framework/AppRunner.php b/tests/App/Framework/AppRunner.php index 9e123eb..30f2a44 100644 --- a/tests/App/Framework/AppRunner.php +++ b/tests/App/Framework/AppRunner.php @@ -13,12 +13,16 @@ namespace Composer\XdebugHandler\Tests\App\Framework; +/** + * This class runs and provides functionality for the xdebug-handler functional + * tests. + */ class AppRunner { /** @var string */ - private $scriptDir; + protected $scriptDir; /** @var PhpExecutor */ - private $phpExecutor; + protected $phpExecutor; public function __construct(?string $scriptDir = null) { @@ -31,6 +35,9 @@ public function __construct(?string $scriptDir = null) $this->phpExecutor = new PhpExecutor(); } + /** + * Runs a script and returns its captured output + */ public function run(string $script, ?PhpOptions $options = null, bool $allow = false): Logs { $script = $this->checkScript($script); @@ -54,19 +61,6 @@ public function run(string $script, ?PhpOptions $options = null, bool $allow = f return new Logs($outputLines); } - public function runScript(string $script, ?PhpOptions $options = null): void - { - $script = $this->checkScript($script); - - if ($options === null) { - $options = new PhpOptions(); - } - - // set passthru in child proccesses so output gets collected - $options->setPassthru(true); - $this->phpExecutor->run($script, $options, $this->scriptDir); - } - public function getAppName(string $script): string { return basename($script, '.php'); @@ -84,7 +78,7 @@ public function getEnvPrefix(string $script): string return strtoupper(str_replace(array('-', ' '), '_', $name)); } - private function checkScript(string $script): string + protected function checkScript(string $script): string { if (file_exists($script)) { return $script; diff --git a/tests/App/Framework/Logs.php b/tests/App/Framework/Logs.php index 4d4974a..9f9f699 100644 --- a/tests/App/Framework/Logs.php +++ b/tests/App/Framework/Logs.php @@ -13,6 +13,40 @@ namespace Composer\XdebugHandler\Tests\App\Framework; +use Composer\Pcre\Preg; + +/** + * This class provides the log output from processes run by the test framework. + * + * The following (truncated) output lines: + * + * app-basic[413] start + * logger[413] Checking APP_BASIC_ALLOW_XDEBUG + * ... + * app-basic[414] start + * logger[414] Checking APP_BASIC_ALLOW_XDEBUG + * ... + * logger[413] Restarted process exited 0 + * + * are stored in the items array as: + * + * items[0] = [ + * { pid: 413, name: app-basic, value: start }, + * { pid: 413, name: logger, value: Checking APP_BASIC_ALLOW_XDEBUG }, + * { pid: 413, name: logger, value: Restarted process exited 0 }, + * ] + * + * items[1] = [ + * { pid: 414, name: app-basic, value: start }, + * { pid: 414, name: logger, value: Checking APP_BASIC_ALLOW_XDEBUG }, + * ] + * + * The zero-based indexes represent the process order. They are accessed by using + * a one-based sequence number in a call to getDefault() or getNamed(). + * + * getDefault(1): returns logger values from process 0 (pid 413) + * getNamed(2, 'app-basic'): returns app-basic values from process 1 (pid 414) + */ class Logs { public const LOGGER_NAME = 'logger'; @@ -21,137 +55,74 @@ class Logs private $items = []; /** - * * @param list $outputLines */ public function __construct(array $outputLines) { - $pids = []; + $indexes = []; foreach ($outputLines as $line) { $line = trim($line); - // @phpstan-ignore booleanNot.exprNotBoolean - if (!preg_match('/^(.+)\\[(\\d+)\\](.+)$/', $line, $matches)) { + if (!Preg::isMatch('/^(.+)\[(\d+)\]\s(.+)$/', $line, $matches)) { continue; } $pid = (int) $matches[2]; - $item = new LogItem($pid, $matches[1], $matches[3]); + $item = new LogItem($pid, (string) $matches[1], (string) $matches[3]); - if (!isset($pids[$pid])) { + if (!isset($indexes[$pid])) { $this->items[] = []; - $pids[$pid] = count($this->items) - 1; + $indexes[$pid] = count($this->items) - 1; } - $index = $pids[$pid]; + $index = $indexes[$pid]; $this->items[$index][] = $item; } } /** + * Provides the debug output from a process * - * @return list - */ - public function getOutputForProcess(int $seqNo): array - { - $index = $seqNo - 1; - - if (isset($this->items[$index])) { - return $this->items[$index]; - } - - return []; - } - - /** + * This is the default xdebug-handler debug output. * - * @return list + * @param int $sequence The one-based sequence number of the process + * @return ProcessLog */ - public function getValuesForProcess(int $seqNo, bool $forLogger = true): array + public function getDefault(int $sequence): ProcessLog { - $items = $this->getOutputForProcess($seqNo); - - if ($forLogger) { - $result = $this->filterValuesForLogger($items); - } else { - $result = $this->filterValuesForScript($items); - } - - return $result; + return $this->getProcessLog($sequence, 'logger'); } /** + * Provides the log output from a named script in a process * - * @return list - */ - public function getValuesForProcessName(int $seqNo, string $name): array - { - $items = $this->getOutputForProcess($seqNo); - - return $this->filterValuesForScriptName($items, $name); - } - - /** + * This is the output from the script. * - * @param list $items + * @param int $sequence The one-based sequence number of the process + * @param string $name The app name to filter on + * @return ProcessLog */ - public function getItemFromList(array $items, int $seqNo, bool $complete = false): string + public function getNamed(int $sequence, string $name): ProcessLog { - $index = $seqNo - 1; - - if (!isset($items[$index])) { - throw new \LogicException('Log item not found at index: '.$index); - } - - $item = $items[$index]; - - if (!$complete) { - return $item->value; - } - - return sprintf('%s[%d] %s', $item->name, $item->pid, $item->value); + return $this->getProcessLog($sequence, $name); } /** + * Provides either the default or named log output from a process * - * @param list $items - * @return list + * @param int $sequence The one-based sequence number of the process + * @param string $name The app name to filter on + * @return ProcessLog */ - private function filterValuesForScript(array $items): array + private function getProcessLog(int $sequence, string $name): ProcessLog { - $result = array_filter($items, function($item) { - return $item->name !== self::LOGGER_NAME; - }); - - return array_values($result); - } + $items = $this->items[$sequence - 1] ?? []; - /** - * - * @param list $items - * @return list - */ - private function filterValuesForScriptName(array $items, string $name): array - { $result = array_filter($items, function($item) use ($name) { return $item->name === $name; }); - return array_values($result); - } - - /** - * - * @param list $items - * @return list - */ - private function filterValuesForLogger(array $items): array - { - $result = array_filter($items, function($item) { - return $item->name === 'logger'; - }); - - return array_values($result); + return new ProcessLog(array_values($result), $sequence); } } diff --git a/tests/App/Framework/MessageProvider.php b/tests/App/Framework/MessageProvider.php new file mode 100644 index 0000000..f7b7beb --- /dev/null +++ b/tests/App/Framework/MessageProvider.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\XdebugHandler\Tests\App\Framework; + +class MessageProvider +{ + /** @var array */ + private $items = []; + + public function __construct(string $envAllow) + { + // type: array of phrases to check for + $this->items = [ + 'check' => ['Checking ', $envAllow], + 'loaded' => ['The Xdebug extension is loaded'], + 'not-loaded' => ['The Xdebug extension is not loaded'], + 'restarting' => ['Process restarting', $envAllow.'='], + 'run-std' => ['Running', PHP_BINARY, '-n', '-c'], + 'run-persist' => ['Running', PHP_BINARY], + 'exit' => ['Restarted process exited 0'], + 'restarted' => ['Restarted (', 'ms). The Xdebug extension is not loaded'], + 'existing' => ['Process called with existing restart settings'], + 'allow' => ['No restart', $envAllow.'=1'], + 'allow-app' => ['No restart', $envAllow.'=', 'Allowed by application'], + 'allow-mode' => ['No restart', $envAllow.'=', 'Allowed by xdebug.mode'], + 'error' => ['No restart'], + ]; + } + + /** + * @param string[] $types + * @return array + */ + public function compose(array $types): array + { + $result = []; + + foreach ($types as $key) { + if (!isset($this->items[$key])) { + throw new \LogicException('Message type not found: '.$key); + } + $result[$key] = $this->items[$key]; + } + + return $result; + } + + /** + * @return array, array> + */ + public function getRestartMessages(bool $persistent = false): array + { + [$main, $child] = $this->getRestartTypes($persistent); + return [$this->compose($main), $this->compose($child)]; + } + + /** + * @return array + */ + public function getNoRestartMessages(?string $allow = null): array + { + $types = $this->getNoRestartTypes($allow); + return $this->compose($types); + } + + /** + * @return array + */ + public function getExistingMessages(): array + { + $types = ['check', 'not-loaded', 'existing']; + return $this->compose($types); + } + + /** + * @return array + */ + public function getErrorMessages(string $error): array + { + $types = ['check', 'loaded', 'error']; + $result = $this->compose($types); + $result['error'][] = $error; + + return $result; + } + + /** + * @return array + */ + public function getRestartTypes(bool $persistent = false): array + { + $main = [ + 'check', + 'loaded', + 'restarting', + $persistent ? 'run-persist' : 'run-std', + 'exit', + ]; + + return [$main, ['check', 'restarted']]; + } + + /** + * @return string[] + */ + public function getNoRestartTypes(?string $allow = null): array + { + $types = ['check']; + + if ($allow === null) { + $types[] = 'not-loaded'; + } else { + $types[] = 'loaded'; + $types[] = $allow; + } + + return $types; + } +} diff --git a/tests/App/Framework/Output.php b/tests/App/Framework/Output.php index ccb89f8..91b026f 100644 --- a/tests/App/Framework/Output.php +++ b/tests/App/Framework/Output.php @@ -18,7 +18,7 @@ class Output { - public const ENV_OUTPUT_INDENT = 'XDEBUG_HANDLER_TEST_INDENT'; + public const ENV_OUTPUT_PIDS = 'XDEBUG_HANDLER_TEST_PID'; /** @var bool */ private $isDisplay; @@ -32,19 +32,8 @@ class Output public function __construct(bool $display) { $this->isDisplay = $display; - $this->indent = 0; $this->pid = (int) getmypid(); - - if (false === getenv(self::ENV_OUTPUT_INDENT)) { - Process::setEnv(self::ENV_OUTPUT_INDENT, '0'); - } else { - $this->indentIncrease(); - } - } - - public function __destruct() - { - $this->indentDecrease(); + $this->indent = $this->calculateIndent(); } public function write(string $message, string $name): void @@ -56,6 +45,29 @@ public function write(string $message, string $name): void print($text); } + private function calculateIndent(): int + { + $envPids = (string) getenv(self::ENV_OUTPUT_PIDS); + + if ('' === $envPids) { + $pids = []; + } else { + $pids = array_map(function ($item) { + return (int) $item; + }, explode(',', $envPids)); + } + + if (!in_array($this->pid, $pids, true)) { + $pids[] = $this->pid; + } elseif ($pids[count($pids) - 1] !== $this->pid) { + array_pop($pids); + } + + Process::setEnv(self::ENV_OUTPUT_PIDS, implode(',', $pids)); + + return (count($pids) - 1) * 2; + } + private function format(string $prefix, string $text, bool $color): string { if ($color) { @@ -79,20 +91,4 @@ private function format(string $prefix, string $text, bool $color): string return $text; } - - private function indentDecrease(): void - { - if (false !== ($indent = getenv(self::ENV_OUTPUT_INDENT))) { - $this->indent = intval($indent) - 2; - Process::setEnv(self::ENV_OUTPUT_INDENT, (string) $this->indent); - } - } - - private function indentIncrease(): void - { - if (false !== ($indent = getenv(self::ENV_OUTPUT_INDENT))) { - $this->indent = intval($indent) + 2; - Process::setEnv(self::ENV_OUTPUT_INDENT, (string) $this->indent); - } - } } diff --git a/tests/App/Framework/ProcessLog.php b/tests/App/Framework/ProcessLog.php new file mode 100644 index 0000000..81f6601 --- /dev/null +++ b/tests/App/Framework/ProcessLog.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\XdebugHandler\Tests\App\Framework; + +class ProcessLog +{ + /** @var list */ + private $items = []; + + /** @var string */ + private $log; + + /** @var int */ + private $sequence; + + /** + * @param list $items + */ + public function __construct(array $items, int $sequence) + { + $this->items = $items; + $this->sequence = $sequence; + + $list = array_map(function ($item) { + /** @var LogItem $item */ + return sprintf('%s[%d] %s', $item->name, $item->pid, $item->value); + }, $items); + + $this->log = implode("\n", $list); + } + + public function getItem(int $index): LogItem + { + if (!isset($this->items[$index])) { + throw new \LogicException('Log item not found at index: '.$index); + } + + return $this->items[$index]; + } + + /** + * @return list + */ + public function getItems(): array + { + return $this->items; + } + + /** + * Returns a string representation of the log output for this process + * + * @param string|null $prefix + * @return string + */ + public function getOutputInfo(?string $prefix = null): string + { + $parts = $prefix !== null ? [$prefix] : []; + $parts[] = sprintf('Output from process #%d: ', $this->sequence); + $parts[] = $this->log; + + return sprintf("%s\n", implode("\n", $parts)); + } +} + diff --git a/tests/App/Framework/Status.php b/tests/App/Framework/Status.php index 7ffdbd6..c335617 100644 --- a/tests/App/Framework/Status.php +++ b/tests/App/Framework/Status.php @@ -16,10 +16,13 @@ use Composer\XdebugHandler\XdebugHandler; /** + * This class provides xdebug-handler settings and values. + * * @phpstan-import-type restartData from \Composer\XdebugHandler\PhpConfig */ class Status { + private const AVAILABLE = 'available'; private const INVALID = 'not applicable'; private const MISSING = 'not available'; @@ -37,6 +40,7 @@ public function __construct(bool $display, bool $showInis) } /** + * Returns a list of xdebug-handler settings and values * * @param class-string $className * @return list @@ -47,10 +51,20 @@ public function getWorkingsStatus(string $className): array $result = []; // status - [$status, $info] = $this->getXdebugStatus(); - $result[] = sprintf('[%s] %s', $status, $info); + $status = $this->getXdebugStatus(); + $result[] = sprintf('[xdebug] %s', $status); + + /** @var restartData|null $settings */ + $settings = $className::getRestartSettings(); + + if ($settings === null) { + $shortSettings = $this->loaded ? self::INVALID : self::MISSING; + } else { + $shortSettings = self::AVAILABLE; + } if (!$this->isDisplay) { + $result[] = sprintf('[settings] %s', $shortSettings); return $result; } @@ -63,35 +77,28 @@ public function getWorkingsStatus(string $className): array $result[] = sprintf('[skipped] %s', $skipped); // envs - foreach($this->getEnvs() as $item) { + foreach ($this->getEnvs() as $item) { $result[] = sprintf('[env] %s', $item); } // inis - foreach($this->getInis($className) as $item) { + foreach ($this->getInis($className) as $item) { $result[] = sprintf('[ini] %s', $item); } // settings - foreach($this->getSettings($className) as $item) { + foreach ($this->getSettings($settings) as $item) { $result[] = sprintf('[settings] %s', $item); } return $result; } - /** - * - * @return array{0: string, 1: string} - */ - private function getXdebugStatus(): array + private function getXdebugStatus(): string { - $status = $this->loaded ? 'XDEBUG' : 'NO XDEBUG'; - $text = 'The Xdebug extension is'; - $suffix = $this->loaded ? 'loaded' : 'not loaded'; - $info = sprintf('%s %s', $text, $suffix); + $suffix = $this->loaded ? 'loaded' : 'NOT loaded'; - return [$status, $info]; + return sprintf('extension %s', $suffix); } /** @@ -151,14 +158,13 @@ private function getEnvs(): array */ private function getInis(string $className): array { - $iniFiles = $this->findIniFiles($className); - + /** @var non-empty-list $iniFiles */ + $iniFiles = $className::getAllIniFiles(); if ($iniFiles[0] === '') { - array_shift($iniFiles); + $iniFiles[0] = '(none)'; } $count = count($iniFiles); - if ($this->showInis || $count === 1) { return $iniFiles; } @@ -168,41 +174,13 @@ private function getInis(string $className): array return [$iniFiles[0], $message]; } - /** - * @param class-string $className - * @return list - */ - private function findIniFiles(string $className): array - { - $extended = $className !== XdebugHandler::class; - - if ($extended) { - $iniFiles = $className::getAllIniFiles(); - } elseif (class_exists(XdebugHandler::class)) { - $iniFiles = $className::getAllIniFiles(); - } else { - $iniFiles = array((string) php_ini_loaded_file()); - $scanned = php_ini_scanned_files(); - - if ($scanned !== false) { - $iniFiles = array_merge($iniFiles, array_map('trim', explode(',', $scanned))); - } - } - - // @phpstan-ignore-next-line - return !is_array($iniFiles) ? [self::MISSING] : $iniFiles; - } - /** * - * @param class-string $className + * @param restartData|null $settings * @return list */ - private function getSettings(string $className): array + private function getSettings(?array $settings): array { - /** @var restartData|null $settings */ - $settings = $className::getRestartSettings(); - if ($settings === null) { return $this->loaded ? [self::INVALID] : [self::MISSING]; } @@ -223,6 +201,7 @@ private function getSettings(string $className): array foreach($settings as $key => $value) { $data[] = sprintf('%s=%s', $key, $value); } + return $data; } } diff --git a/tests/App/Helpers/FunctionalTestCase.php b/tests/App/Helpers/FunctionalTestCase.php index 2f0be40..3dc5a4a 100644 --- a/tests/App/Helpers/FunctionalTestCase.php +++ b/tests/App/Helpers/FunctionalTestCase.php @@ -16,6 +16,8 @@ use Composer\XdebugHandler\Tests\App\Framework\AppRunner; use Composer\XdebugHandler\Tests\App\Framework\LogItem; use Composer\XdebugHandler\Tests\App\Framework\Logs; +use Composer\XdebugHandler\Tests\App\Framework\MessageProvider; +use Composer\XdebugHandler\Tests\App\Framework\ProcessLog; use PHPUnit\Framework\TestCase; abstract class FunctionalTestCase extends TestCase @@ -38,35 +40,68 @@ protected function compareXdebugVersion(string $version, string $op, ?string &$a return version_compare($actual, $version, $op); } - protected function checkRestart(Logs $logs, ?int $childCount = null): void + /** + * @param Logs $logs + * @param array $mainMessages + * @param array $childMessages + * @return void + */ + protected function checkRestart(Logs $logs, array $mainMessages, array $childMessages): void { - $main = $logs->getValuesForProcess(1); - $child = $logs->getValuesForProcess(2); + $mainLogs = $logs->getDefault(1); + $this->checkExpectedMessages($mainMessages, $mainLogs, 'main'); - self::assertCount(5, $main); + $childLogs = $logs->getDefault(2); + $this->checkExpectedMessages($childMessages, $childLogs, 'child'); + } - $count = $childCount === null ? 2 : $childCount; - self::assertCount($count, $child); + /** + * @param Logs $logs + * @param array $messages + * @return void + */ + protected function checkNoRestart(Logs $logs, array $messages): void + { + $main = $logs->getDefault(1); + $this->checkExpectedMessages($messages, $main, 'main'); - if ($childCount === null) { - $item = $logs->getItemFromList($child, 2); - self::assertStringContainsString('The Xdebug extension is not loaded', $item); - } + $child = $logs->getDefault(2); + self::assertCount(0, $child->getItems()); } - protected function checkNoRestart(Logs $logs, ?string $extraExpected = null): void + /** + * @param array $expected + * @param ProcessLog $log + * @param string $name + * @return void + */ + protected function checkExpectedMessages(array $expected, ProcessLog $log, string $name): void { - $main = $logs->getValuesForProcess(1); - $child = $logs->getValuesForProcess(2); - - self::assertCount(3, $main); - self::assertCount(0, $child); + $assertMessage = $log->getOutputInfo($name); + self::assertCount(count($expected), $log->getItems(), $assertMessage); + $index = 0; - $item = $logs->getItemFromList($main, 3); - self::assertStringContainsString('No restart', $item); + foreach ($expected as $type => $messages) { + $item = $log->getItem($index); + $assertMessage = $log->getOutputInfo(sprintf('%s: %s', $name, $type)); + $index += 1; - if ($extraExpected !== null) { - self::assertStringContainsString($extraExpected, $item); + foreach ($messages as $message) { + self::assertStringContainsString($message, $item->value, $assertMessage); + } } } -} \ No newline at end of file + + protected function checkExpectedMessage(ProcessLog $log, int $index, string $expected): void + { + $message = $log->getOutputInfo(); + $item = $log->getItem($index); + self::assertStringContainsString($expected, $item->value, $message); + } + + protected function createMessageProvider(string $script): MessageProvider + { + $envAllow = $this->runner->getEnvAllow($script); + return new MessageProvider($envAllow); + } +} diff --git a/tests/App/README.md b/tests/App/README.md index 2415c99..5f228a5 100644 --- a/tests/App/README.md +++ b/tests/App/README.md @@ -50,12 +50,12 @@ php app-extend-allow.php -h --display ### app-extend-ini Demonstrates how an extended class can restart with a specific ini setting, even if xdebug is not -loaded. The `phar_readonly` ini setting is used in this example. +loaded. The `phar.readonly` ini setting is used in this example. ```sh cd tests/App -php -dphar.read_only=1 app-extend-ini.php --display +php -dphar.readonly=1 app-extend-ini.php --display ``` ### app-extend-mode @@ -97,11 +97,7 @@ merged, so it is available in the restart. If stdin is used, it will be read in the restart: ```sh -# Unixy -cat app-plain.php | php -dauto_prepend_file=app-prepend.php -- --display - -# Windows -type app-plain.php | php -dauto_prepend_file=app-prepend.php -- --display +php -dauto_prepend_file=app-prepend.php -- --display < app-plain.php ``` ### app-stdin @@ -114,11 +110,7 @@ Uses xdebug-handler from stdin to remove xdebug from a script. Note that either ```sh cd tests/App -# Unixy -cat app-stdin.php | php -- app-plain.php --display - -# Windows -type app-stdin.php | php -- app-plain.php --display +php -- app-plain.php --display < app-stdin.php ``` While this works in simple scenarios, it is not recommended because the restart settings are not diff --git a/tests/App/app-basic.php b/tests/App/app-basic.php index d8fee4b..a15d2fd 100644 --- a/tests/App/app-basic.php +++ b/tests/App/app-basic.php @@ -19,7 +19,7 @@ /** * Restarts without xdebug * - * Usage: php app.php + * Usage: php app-basic.php */ $app = new AppHelper(__FILE__); diff --git a/tests/App/app-extend-ini.php b/tests/App/app-extend-ini.php index ffdac27..79c4139 100644 --- a/tests/App/app-extend-ini.php +++ b/tests/App/app-extend-ini.php @@ -18,9 +18,9 @@ use Composer\XdebugHandler\XdebugHandler; /** - * Restarts without xdebug with phar.read_only=0, even if xdebug is not loaded + * Restarts without xdebug with phar.readonly=0, even if xdebug is not loaded * - * Usage: php -dphar.read_only=1 app-extend-ini.php + * Usage: php -dphar.readonly=1 app-extend-ini.php */ $app = new AppHelper(__FILE__); diff --git a/tests/App/app-persistent.php b/tests/App/app-persistent.php index cb1a8d3..79ca271 100644 --- a/tests/App/app-persistent.php +++ b/tests/App/app-persistent.php @@ -21,7 +21,7 @@ * Restarts without xdebug in persistent mode, then calls sub-processes: * - app-plain.php (xdebug not loaded) * - app-plain.php with original settings (xdebug will be loaded) - * - app-other.php (xdebug not loaded) + * - app-basic.php (xdebug not loaded) * * Usage: php app-persistent.php */ diff --git a/tests/App/app-plain.php b/tests/App/app-plain.php index 664e974..600287b 100644 --- a/tests/App/app-plain.php +++ b/tests/App/app-plain.php @@ -18,7 +18,7 @@ /** * A plain php script that does not include xdebug-handler * - * Usage: php app-none.php + * Usage: php app-plain.php */ $app = new AppHelper(__FILE__); diff --git a/tests/App/app-prepend.php b/tests/App/app-prepend.php index fa6f172..8ebd534 100644 --- a/tests/App/app-prepend.php +++ b/tests/App/app-prepend.php @@ -25,8 +25,7 @@ * ini content is merged, so it is available in the restart. * * If stdin is used, it will be read in the restart: - * Usage (Unixy) : cat app-plain.php | php -dauto_prepend_file=app-prepend.php - * Usage (Windows) : type app-plain.php | php -dauto_prepend_file=app-prepend.php + * Usage: php -dauto_prepend_file=app-prepend.php < app-plain.php */ $app = new AppHelper(__FILE__); diff --git a/tests/App/app-standard.php b/tests/App/app-standard.php new file mode 100644 index 0000000..b64afc5 --- /dev/null +++ b/tests/App/app-standard.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +require __DIR__.'/../../vendor/autoload.php'; + +use Composer\XdebugHandler\Tests\App\Framework\AppHelper; +use Composer\XdebugHandler\Tests\App\Framework\PhpOptions; +use Composer\XdebugHandler\XdebugHandler; +use Composer\XdebugHandler\PhpConfig; + +/** + * Restarts without xdebug in standard mode, then calls sub-processes: + * - app-plain.php (xdebug will be loaded) + * - app-basic.php with standard settings (xdebug not loaded) + * + * Usage: php app-standard.php + */ + +$app = new AppHelper(__FILE__); + +$app->write('start'); + +$xdebug = $app->getXdebugHandler(null); +$xdebug->check(); + +$app->writeXdebugStatus(); + +// Simple call to app-plain (xdebug will be loaded) +$app->write('CALL app-plain.php'); +$app->write('- if we are in a restart, xdebug will be loaded'); +$app->runScript('app-plain.php'); + +// app-basic with xdebug +$app->write('CALL app-basic.php with standard settings'); +$app->write('- if we are in a restart, xdebug should not be loaded'); + +$phpConfig = new PhpConfig(); +$args = $phpConfig->useStandard(); +$options = new PhpOptions(); +$options->addPhpArgs(...$args); + +$app->runScript('app-basic.php', $options); +$app->write('finish'); diff --git a/tests/App/app-stdin.php b/tests/App/app-stdin.php index 716ef6b..361b87c 100644 --- a/tests/App/app-stdin.php +++ b/tests/App/app-stdin.php @@ -19,9 +19,7 @@ /** * Restarts without xdebug using stdin * - * Usage (Unixy) : cat app-stdin.php | php -- app-plain.php - * Usage (Windows) : type app-stdin.php | php -- app-plain.php - * + * Usage: php -- app-plain.php < app-stdin.php */ $app = new AppHelper(__FILE__); @@ -36,8 +34,8 @@ $settings = null; if ($mainScript === 'Standard input code' && isset($argv[1])) { $settings = ['mainScript' => $argv[1]]; - // @phpstan-ignore-next-line - unset($_SERVER['argv'][1]); + // @phpstan-ignore argument.type + array_splice($_SERVER['argv'], 1, 1); } $xdebug = $app->getXdebugHandler(null, $settings); diff --git a/tests/AppNoRestartTest.php b/tests/AppNoRestartTest.php index ab4c757..6e32b8b 100644 --- a/tests/AppNoRestartTest.php +++ b/tests/AppNoRestartTest.php @@ -14,12 +14,13 @@ namespace Composer\XdebugHandler\Tests; use Composer\XdebugHandler\Tests\App\Framework\AppRunner; +use Composer\XdebugHandler\Tests\App\Framework\Messages; use Composer\XdebugHandler\Tests\App\Framework\PhpOptions; use Composer\XdebugHandler\Tests\App\Helpers\FunctionalTestCase; use PHPUnit\Framework\TestCase; /** - * @group functional + * @group xdebug-functional */ class AppNoRestartTest extends FunctionalTestCase { @@ -33,12 +34,10 @@ public function testNoRestartWhenAllowed(): void $script = 'app-basic.php'; $logs = $this->runner->run($script, null, true); - $this->checkNoRestart($logs); - $appName = $this->runner->getAppName($script); - $child = $logs->getValuesForProcessName(1, $appName); - $item = $logs->getItemFromList($child, 2); - self::assertStringContainsString('The Xdebug extension is loaded', $item); + $provider = $this->createMessageProvider($script); + $expected = $provider->getNoRestartMessages('allow'); + $this->checkNoRestart($logs, $expected); } /** @@ -58,7 +57,10 @@ public function testNoRestartWhenModeOff(): void $options->addPhpArgs('-dxdebug.mode=off'); $logs = $this->runner->run($script, $options); - $this->checkNoRestart($logs, 'Allowed by xdebug.mode'); + + $provider = $this->createMessageProvider($script); + $expected = $provider->getNoRestartMessages('allow-mode'); + $this->checkNoRestart($logs, $expected); } /** @@ -74,7 +76,10 @@ function testNoRestartWhenConfigError(): void $options->addPhpArgs('-ddisable_functions=proc_open'); $logs = $this->runner->run($script, $options); - $this->checkNoRestart($logs, 'proc_open function is disabled'); + + $provider = $this->createMessageProvider($script); + $expected = $provider->getErrorMessages('proc_open function is disabled'); + $this->checkNoRestart($logs, $expected); } /** @@ -90,6 +95,9 @@ function testNoRestartWhenAllowedByApplication(): void $options->addScriptArgs('--help'); $logs = $this->runner->run($script, $options); - $this->checkNoRestart($logs, 'Allowed by application'); + + $provider = $this->createMessageProvider($script); + $expected = $provider->getNoRestartMessages('allow-app'); + $this->checkNoRestart($logs, $expected); } } diff --git a/tests/AppNoXdebugTest.php b/tests/AppNoXdebugTest.php new file mode 100644 index 0000000..843179b --- /dev/null +++ b/tests/AppNoXdebugTest.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Composer\XdebugHandler\Tests; + +use Composer\XdebugHandler\Tests\App\Framework\AppRunner; +use Composer\XdebugHandler\Tests\App\Framework\Messages; +use Composer\XdebugHandler\Tests\App\Framework\PhpOptions; +use Composer\XdebugHandler\Tests\App\Helpers\FunctionalTestCase; +use PHPUnit\Framework\TestCase; + +class AppNoXdebugTest extends FunctionalTestCase +{ + /** + * Tests no restart when xdebug is not loaded + */ + public function testNoRestartWithNoXdebug(): void + { + if (extension_loaded('xdebug')) { + self::markTestSkipped('xdebug must not be loaded for this test'); + } + + $script = 'app-basic.php'; + + $logs = $this->runner->run($script); + + $provider = $this->createMessageProvider($script); + $expected = $provider->compose(['check', 'not-loaded']); + $this->checkNoRestart($logs, $expected); + } + + /** + * Tests a forced restart when xdebug is not loaded + */ + public function testForcedRestartWithNoXdebug(): void + { + if (extension_loaded('xdebug')) { + self::markTestSkipped('xdebug must not be loaded for this test'); + } + + $script = 'app-extend-ini.php'; + + $options = new PhpOptions(); + $options->addPhpArgs('-dphar.readonly=1'); + + $logs = $this->runner->run($script, $options); + + $provider = $this->createMessageProvider($script); + [$mainTypes, $childTypes] = $provider->getRestartTypes(); + + $index = array_search('loaded', $mainTypes, true); + if (is_int($index)) { + $mainTypes[$index] = 'not-loaded'; + } + + $main = $provider->compose($mainTypes); + $child = $provider->compose($childTypes); + $this->checkRestart($logs, $main, $child); + + // check child + $processLog = $logs->getNamed(2, 'app-extend-ini'); + + // check ini setting has changed at index 1 + $expected = 'phar.readonly=0'; + $this->checkExpectedMessage($processLog, 1, $expected); + } +} diff --git a/tests/AppRestartTest.php b/tests/AppRestartTest.php index ab47f57..5456ac6 100644 --- a/tests/AppRestartTest.php +++ b/tests/AppRestartTest.php @@ -15,16 +15,18 @@ use Composer\XdebugHandler\Tests\App\Framework\AppRunner; use Composer\XdebugHandler\Tests\App\Framework\LogItem; +use Composer\XdebugHandler\Tests\App\Framework\Messages; use Composer\XdebugHandler\Tests\App\Framework\PhpOptions; use Composer\XdebugHandler\Tests\App\Helpers\FunctionalTestCase; use PHPUnit\Framework\TestCase; /** - * @group functional + * @group xdebug-functional */ class AppRestartTest extends FunctionalTestCase { /** + * Tests basic restart functionality * * @requires extension xdebug */ @@ -33,10 +35,14 @@ public function testRestart(): void $script = 'app-basic.php'; $logs = $this->runner->run($script); - $this->checkRestart($logs); + + $provider = $this->createMessageProvider($script); + [$main, $child] = $provider->getRestartMessages(); + $this->checkRestart($logs, $main, $child); } /** + * Tests a restart using an auto_prepend_file * * @requires extension xdebug */ @@ -48,12 +54,21 @@ public function testRestartWithPrependFile(): void $options->addPhpArgs('-dauto_prepend_file=app-prepend.php'); $logs = $this->runner->run($script, $options); - $this->checkRestart($logs); - // check app-plain has run - $appName = $this->runner->getAppName($script); - $child = $logs->getValuesForProcessName(2, $appName); - self::assertNotEmpty($child); + $provider = $this->createMessageProvider('app-prepend'); + [$main, $child] = $provider->getRestartMessages(); + $this->checkRestart($logs, $main, $child); + + // check app-plain + $processLog = $logs->getNamed(2, 'app-plain'); + + // check not loaded at index 1 + $expected = '[xdebug] extension NOT loaded'; + $this->checkExpectedMessage($processLog, 1, $expected); + + // check settings available at index 2 + $expected = '[settings] available'; + $this->checkExpectedMessage($processLog, 2, $expected); } /** @@ -69,12 +84,21 @@ public function testRestartWithPrependFileStdin(): void $options->setStdin(true); $logs = $this->runner->run($script, $options); - $this->checkRestart($logs); - // check app-plain has run - $appName = 'Standard input code'; - $child = $logs->getValuesForProcessName(2, $appName); - self::assertNotEmpty($child); + $provider = $this->createMessageProvider('app-prepend.php'); + [$main, $child] = $provider->getRestartMessages(); + $this->checkRestart($logs, $main, $child); + + // check app-plain + $processLog = $logs->getNamed(2, 'Standard input code'); + + // check not loaded at index 1 + $expected = '[xdebug] extension NOT loaded'; + $this->checkExpectedMessage($processLog, 1, $expected); + + // check settings available at index 2 + $expected = '[settings] available'; + $this->checkExpectedMessage($processLog, 2, $expected); } /** @@ -91,13 +115,22 @@ public function testRestartWithStdin(): void $options->setStdin(true); $logs = $this->runner->run($script, $options); - $this->checkRestart($logs, 0); - // check not loaded from app-plain - $appName = $this->runner->getAppName('app-plain.php'); - $child = $logs->getValuesForProcessName(2, $appName); - $item = $logs->getItemFromList($child, 2); - self::assertStringContainsString('The Xdebug extension is not loaded', $item); + $provider = $this->createMessageProvider('Standard input code'); + [$main] = $provider->getRestartMessages(); + // app-plain does not implement xdebug-handler so there is no logger output + $this->checkRestart($logs, $main, []); + + // check app-plain + $processLog = $logs->getNamed(2, 'app-plain'); + + // check not loaded at index 1 + $expected = '[xdebug] extension NOT loaded'; + $this->checkExpectedMessage($processLog, 1, $expected); + + // check settings not available at index 2 + $expected = '[settings] not available'; + $this->checkExpectedMessage($processLog, 2, $expected); } /** @@ -109,23 +142,27 @@ public function testRestartWithIniChange(): void $script = 'app-extend-ini.php'; $options = new PhpOptions(); - $options->addPhpArgs('-dphar.read_only=1'); + $options->addPhpArgs('-dphar.readonly=1'); $logs = $this->runner->run($script, $options); - $this->checkRestart($logs); - // check ini setting has changed in child - $appName = $this->runner->getAppName($script); - $child = $logs->getValuesForProcessName(2, $appName); - $item = $logs->getItemFromList($child, 2); - self::assertStringContainsString('phar.readonly=0', $item); + $provider = $this->createMessageProvider($script); + [$main, $child] = $provider->getRestartMessages(); + $this->checkRestart($logs, $main, $child); + + // check child + $processLog = $logs->getNamed(2, 'app-extend-ini'); + + // check ini setting has changed at index 1 + $expected = 'phar.readonly=0'; + $this->checkExpectedMessage($processLog, 1, $expected); } /** * * @requires extension xdebug */ - public function testRestartWithXdebug(): void + public function testRestartWithXdebugModeChange(): void { $version = (string) phpversion('xdebug'); @@ -139,43 +176,104 @@ public function testRestartWithXdebug(): void $options->addPhpArgs('-dxdebug.mode=develop'); $logs = $this->runner->run($script, $options); - $this->checkRestart($logs, 2); - $child = $logs->getValuesForProcess(2); - $item = $logs->getItemFromList($child, 2); - self::assertStringContainsString('The Xdebug extension is loaded', $item); - self::assertStringContainsString('xdebug.mode=coverage', $item); + $provider = $this->createMessageProvider($script); + [$main, $child] = $provider->getRestartMessages(); + + // Modify restarted messages + $child['restarted'][1] = 'The Xdebug extension is loaded'; + $child['restarted'][2] = 'xdebug.mode=coverage'; + $this->checkRestart($logs, $main, $child); } /** * * @requires extension xdebug */ - public function testRestartWithProcessPersistent(): void + public function testRestartChildInStandardConfig(): void + { + $script = 'app-standard.php'; + + $logs = $this->runner->run($script); + + $provider = $this->createMessageProvider($script); + [$main, $child] = $provider->getRestartMessages(); + $this->checkRestart($logs, $main, $child); + + // first sub-process - app-plain + $processLog = $logs->getNamed(3, 'app-plain'); + + // check loaded at index 1 + $expected = 'extension loaded'; + $this->checkExpectedMessage($processLog, 1, $expected); + + // check settings not applicable at index 2 + $expected = '[settings] not applicable'; + $this->checkExpectedMessage($processLog, 2, $expected); + + // second sub-process - app-basic + $processLog = $logs->getDefault(4); + + $provider = $this->createMessageProvider('app-basic'); + $expected = $provider->getExistingMessages(); + $this->checkExpectedMessages($expected, $processLog, 'app-basic'); + + // get app-basic logs + $processLog = $logs->getNamed(4, 'app-basic'); + + // check settings available at index 2 + $expected = '[settings] available'; + $this->checkExpectedMessage($processLog, 2, $expected); + } + + /** + * + * @requires extension xdebug + */ + public function testRestartChildInPersistentConfig(): void { $script = 'app-persistent.php'; $logs = $this->runner->run($script); - $this->checkRestart($logs); - - // first sub-process - check not loaded from app-plain - $appName = $this->runner->getAppName('app-plain.php'); - $child = $logs->getValuesForProcessName(3, $appName); - $item = $logs->getItemFromList($child, 2); - self::assertStringContainsString('The Xdebug extension is not loaded', $item); - - // second sub-process - check loaded from app-plain - $appName = $this->runner->getAppName('app-plain.php'); - $child = $logs->getValuesForProcessName(4, $appName); - $item = $logs->getItemFromList($child, 2); - self::assertStringContainsString('The Xdebug extension is loaded', $item); - - // third sub-process - check not loaded from app-basic - $child = $logs->getValuesForProcess(5); - $item = $logs->getItemFromList($child, 2); - self::assertStringContainsString('The Xdebug extension is not loaded', $item); - - $item = $logs->getItemFromList($child, 3); - self::assertStringContainsString('Process called with existing restart settings', $item); + + $provider = $this->createMessageProvider($script); + [$main, $child] = $provider->getRestartMessages(true); + $this->checkRestart($logs, $main, $child); + + // first sub-process - app-plain + $processLog = $logs->getNamed(3, 'app-plain'); + + // check not loaded at index 1 + $expected = 'extension NOT loaded'; + $this->checkExpectedMessage($processLog, 1, $expected); + + // check settings available at index 2 + $expected = '[settings] available'; + $this->checkExpectedMessage($processLog, 2, $expected); + + // second sub-process - app-plain + $processLog = $logs->getNamed(4, 'app-plain'); + + // check loaded at index 1 + $expected = 'extension loaded'; + $this->checkExpectedMessage($processLog, 1, $expected); + + // check settings not applicable at index 2 + $expected = '[settings] not applicable'; + $this->checkExpectedMessage($processLog, 2, $expected); + + // third sub-process - app-basic + $processLog = $logs->getDefault(5); + + $provider = $this->createMessageProvider('app-basic'); + $expected = $provider->getExistingMessages(); + $this->checkExpectedMessages($expected, $processLog, 'app-basic'); + + // get app-basic logs + $processLog = $logs->getNamed(5, 'app-basic'); + + // check settings available at index 2 + $expected = '[settings] available'; + $this->checkExpectedMessage($processLog, 2, $expected); } } diff --git a/tests/Helpers/IniHelper.php b/tests/Helpers/IniHelper.php index 02bff85..3d75a76 100644 --- a/tests/Helpers/IniHelper.php +++ b/tests/Helpers/IniHelper.php @@ -142,7 +142,7 @@ private function setEnvironment(): void $this->setEnv(CoreMock::ORIGINAL_INIS, implode(PATH_SEPARATOR, $this->files)); if ($this->envOptions !== null) { - list($scanDir, $phprc) = $this->envOptions; + [$scanDir, $phprc] = $this->envOptions; $this->setEnv('PHP_INI_SCAN_DIR', $scanDir); $this->setEnv('PHPRC', $phprc); } diff --git a/tests/Mocks/CoreMock.php b/tests/Mocks/CoreMock.php index 94a8894..2cf00b7 100644 --- a/tests/Mocks/CoreMock.php +++ b/tests/Mocks/CoreMock.php @@ -67,7 +67,7 @@ public static function createAndCheck($loaded, ?self $parentProcess = null, arra $mode = null; if (is_array($loaded)) { - list($loaded, $mode) = $loaded; + [$loaded, $mode] = $loaded; } if (is_string($mode) && !$loaded) {