diff --git a/.github/workflows/functional.yml b/.github/workflows/functional.yml
new file mode 100644
index 0000000..6e579f1
--- /dev/null
+++ b/.github/workflows/functional.yml
@@ -0,0 +1,69 @@
+name: Functional Tests
+
+on:
+ push:
+ paths-ignore: ["**.md"]
+ pull_request:
+ paths-ignore: ["**.md"]
+
+env:
+ COMPOSER_FLAGS: --ansi --no-interaction --no-progress --prefer-dist
+
+jobs:
+ tests:
+ name: Functional Tests
+
+ runs-on: ${{ matrix.os }}
+ continue-on-error: ${{ matrix.experimental }}
+
+ strategy:
+ matrix:
+ php-version:
+ - "7.2"
+ - "8.3"
+ os: [ubuntu-latest]
+ experimental: [false]
+ include:
+ - php-version: "7.2"
+ os: windows-latest
+ experimental: false
+ - php-version: "8.3"
+ os: windows-latest
+ experimental: false
+ - php-version: "8.4"
+ os: ubuntu-latest
+ experimental: true
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ coverage: xdebug
+ php-version: ${{ matrix.php-version }}
+
+ - name: Get composer cache directory
+ id: composercache
+ shell: bash
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Restore cached dependencies
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composercache.outputs.dir }}
+ key: php-${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }}
+ restore-keys: php-${{ matrix.php-version }}-composer-
+
+ - name: Install latest dependencies
+ run: composer update ${{ env.COMPOSER_FLAGS }}
+
+ - name: Run tests
+ if: ${{ !matrix.experimental }}
+ run: vendor/bin/phpunit --group functional
+
+ # Show deprecations on PHP 8.4
+ - name: Run tests (experimental)
+ if: ${{ matrix.experimental }}
+ run: vendor/bin/phpunit --group functional --display-deprecations
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 8fce37b..717aec4 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -8,4 +8,10 @@
tests
+
+
+
+ functional
+
+
diff --git a/tests/App/Framework/AppHelper.php b/tests/App/Framework/AppHelper.php
new file mode 100644
index 0000000..9a4f4e0
--- /dev/null
+++ b/tests/App/Framework/AppHelper.php
@@ -0,0 +1,185 @@
+
+ *
+ * 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;
+
+use Composer\XdebugHandler\Process;
+use Composer\XdebugHandler\XdebugHandler;
+
+class AppHelper extends AppRunner
+{
+ public const ENV_IS_DISPLAY = 'XDEBUG_HANDLER_TEST_DISPLAY';
+ public const ENV_SHOW_INIS = 'XDEBUG_HANDLER_TEST_INIS';
+
+ /** @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;
+
+ /** @var Output */
+ private $output;
+
+ /** @var Status */
+ private $status = null;
+
+ public function __construct(string $script)
+ {
+ parent::__construct(dirname($script));
+ $this->appName = $this->getAppName($script);
+ $this->envPrefix = $this->getEnvPrefix($script);
+
+ $this->display = $this->getDisplayFromServer();
+ $this->showInis = $this->getInisFromServer();
+ $this->setServerArgv();
+
+ $this->output = new Output($this->display);
+ $this->logger = new Logger($this->output);
+ $this->status = new Status($this->display, $this->showInis);
+ }
+
+ /**
+ *
+ * @return non-empty-list
+ */
+ public function getServerArgv(): array
+ {
+ return $this->serverArgv;
+ }
+
+ public function getServerArgv0(): string
+ {
+ return $this->getServerArgv()[0];
+ }
+
+ /**
+ *
+ * @param class-string $class
+ * @param array $settings
+ */
+ public function getXdebugHandler(?string $class = null, ?array $settings = null): XdebugHandler
+ {
+ if ($class === null) {
+ $class = XdebugHandler::class;
+ } elseif (!is_subclass_of($class, XdebugHandler::class)) {
+ throw new \RuntimeException($class.' must extend XdebugHandler');
+ }
+
+ $this->className = $class;
+
+ /** @var XdebugHandler $xdebug */
+ $xdebug = new $class($this->envPrefix);
+ $xdebug->setLogger($this->logger);
+
+ if (isset($settings['mainScript'])) {
+ $xdebug->setMainScript($settings['mainScript']);
+ }
+
+ if (isset($settings['persistent'])) {
+ $xdebug->setPersistent();
+ }
+
+ return $xdebug;
+ }
+
+ public function runScript(string $script, ?PhpOptions $options = null): void
+ {
+ parent::runScript($script, $options);
+ }
+
+ public function write(string $message): void
+ {
+ $this->logger->write($message, $this->appName);
+ }
+
+ public function writeXdebugStatus(): void
+ {
+ $className = $this->className ?? XdebugHandler::class;
+ $items = $this->status->getWorkingsStatus($className);
+
+ foreach($items as $item) {
+ $this->write('working '.$item);
+ }
+ }
+
+ private function setServerArgv(): void
+ {
+ $args = [];
+ $errors = false;
+
+ if (isset($_SERVER['argv']) && is_array($_SERVER['argv'])) {
+ foreach ($_SERVER['argv'] as $value) {
+ if (!is_string($value)) {
+ $errors = true;
+ break;
+ }
+
+ $args[] = $value;
+ }
+ }
+
+ if ($errors || count($args) === 0) {
+ throw new \RuntimeException('$_SERVER[argv] is not as expected');
+ }
+
+ $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 ($key !== false) {
+ $result = true;
+ Process::setEnv(self::ENV_IS_DISPLAY, '1');
+ unset($_SERVER['argv'][$key]);
+ } 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 ($key !== false) {
+ $result = true;
+ Process::setEnv(self::ENV_SHOW_INIS, '1');
+ unset($_SERVER['argv'][$key]);
+ } else {
+ $result = false !== getenv(self::ENV_SHOW_INIS);
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/tests/App/Framework/AppRunner.php b/tests/App/Framework/AppRunner.php
new file mode 100644
index 0000000..9e123eb
--- /dev/null
+++ b/tests/App/Framework/AppRunner.php
@@ -0,0 +1,101 @@
+
+ *
+ * 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 AppRunner
+{
+ /** @var string */
+ private $scriptDir;
+ /** @var PhpExecutor */
+ private $phpExecutor;
+
+ public function __construct(?string $scriptDir = null)
+ {
+ $this->scriptDir = (string) realpath($scriptDir ?? __DIR__);
+
+ if (!is_dir($this->scriptDir)) {
+ throw new \RuntimeException('Directory does not exist: '.$this->scriptDir);
+ }
+
+ $this->phpExecutor = new PhpExecutor();
+ }
+
+ public function run(string $script, ?PhpOptions $options = null, bool $allow = false): Logs
+ {
+ $script = $this->checkScript($script);
+
+ if ($options === null) {
+ $options = new PhpOptions();
+ }
+
+ // enforce output
+ $options->setPassthru(false);
+
+ if ($allow) {
+ $options->addEnv($this->getEnvAllow($script), '1');
+ }
+
+ $output = $this->phpExecutor->run($script, $options, $this->scriptDir);
+
+ $lines = preg_split("/\r\n|\n\r|\r|\n/", trim($output));
+ $outputLines = $lines !== false ? $lines : [];
+
+ 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');
+ }
+
+ public function getEnvAllow(string $script): string
+ {
+ return sprintf('%s_ALLOW_XDEBUG', $this->getEnvPrefix($script));
+ }
+
+ public function getEnvPrefix(string $script): string
+ {
+ $name = $this->getAppName($script);
+
+ return strtoupper(str_replace(array('-', ' '), '_', $name));
+ }
+
+ private function checkScript(string $script): string
+ {
+ if (file_exists($script)) {
+ return $script;
+ }
+
+ $path = $this->scriptDir.'/'.$script;
+
+ if (file_exists($path)) {
+ return $path;
+ }
+
+ throw new \RuntimeException('File does not exist: '.$script);
+ }
+}
diff --git a/tests/App/Framework/LogItem.php b/tests/App/Framework/LogItem.php
new file mode 100644
index 0000000..d3ec6ca
--- /dev/null
+++ b/tests/App/Framework/LogItem.php
@@ -0,0 +1,31 @@
+
+ *
+ * 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 LogItem
+{
+ /** @var int */
+ public $pid;
+ /** @var string */
+ public $name;
+ /** @var string */
+ public $value;
+
+ public function __construct(int $pid, string $name, string $value)
+ {
+ $this->pid = $pid;
+ $this->name = $name;
+ $this->value = $value;
+ }
+}
diff --git a/tests/App/Framework/Logger.php b/tests/App/Framework/Logger.php
new file mode 100644
index 0000000..a0fb1b5
--- /dev/null
+++ b/tests/App/Framework/Logger.php
@@ -0,0 +1,41 @@
+
+ *
+ * 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;
+
+use Psr\Log\AbstractLogger;
+
+class Logger extends AbstractLogger
+{
+ /** @var Output $output */
+ private $output;
+
+ public function __construct(Output $output)
+ {
+ $this->output = $output;
+ }
+
+ /**
+ * @inheritdoc
+ * @phpstan-param mixed[] $context
+ */
+ public function log($level, $message, array $context = array()): void
+ {
+ $this->write((string) $message, Logs::LOGGER_NAME);
+ }
+
+ public function write(string $message, string $name): void
+ {
+ $this->output->write($message, $name);
+ }
+}
diff --git a/tests/App/Framework/Logs.php b/tests/App/Framework/Logs.php
new file mode 100644
index 0000000..148f671
--- /dev/null
+++ b/tests/App/Framework/Logs.php
@@ -0,0 +1,156 @@
+
+ *
+ * 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 Logs
+{
+ public const LOGGER_NAME = 'logger';
+
+ /** @var array> */
+ private $items = [];
+
+ /**
+ *
+ * @param list $outputLines
+ */
+ public function __construct(array $outputLines)
+ {
+ $pids = [];
+
+ foreach ($outputLines as $line) {
+ $line = trim($line);
+
+ if (!(bool) preg_match('/^(.+)\\[(\\d+)\\](.+)$/', $line, $matches)) {
+ continue;
+ }
+
+ $pid = (int) $matches[2];
+ $item = new LogItem($pid, $matches[1], $matches[3]);
+
+ if (!isset($pids[$pid])) {
+ $this->items[] = [];
+ $pids[$pid] = count($this->items) - 1;
+ }
+
+ $index = $pids[$pid];
+ $this->items[$index][] = $item;
+ }
+ }
+
+ /**
+ *
+ * @return list
+ */
+ public function getOutputForProcess(int $seqNo): array
+ {
+ $index = $seqNo - 1;
+
+ if (isset($this->items[$index])) {
+ return $this->items[$index];
+ }
+
+ return [];
+ }
+
+ /**
+ *
+ * @return list
+ */
+ public function getValuesForProcess(int $seqNo, bool $forLogger = true): array
+ {
+ $items = $this->getOutputForProcess($seqNo);
+
+ if ($forLogger) {
+ $result = $this->filterValuesForLogger($items);
+ } else {
+ $result = $this->filterValuesForScript($items);
+ }
+
+ return $result;
+ }
+
+ /**
+ *
+ * @return list
+ */
+ public function getValuesForProcessName(int $seqNo, string $name): array
+ {
+ $items = $this->getOutputForProcess($seqNo);
+
+ return $this->filterValuesForScriptName($items, $name);
+ }
+
+ /**
+ *
+ * @param list $items
+ */
+ public function getItemFromList(array $items, int $seqNo, bool $complete = false): string
+ {
+ $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);
+ }
+
+ /**
+ *
+ * @param list $items
+ * @return list
+ */
+ private function filterValuesForScript(array $items): array
+ {
+ $result = array_filter($items, function($item) {
+ return $item->name !== self::LOGGER_NAME;
+ });
+
+ return array_values($result);
+ }
+
+ /**
+ *
+ * @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);
+ }
+}
diff --git a/tests/App/Framework/Output.php b/tests/App/Framework/Output.php
new file mode 100644
index 0000000..07e1d41
--- /dev/null
+++ b/tests/App/Framework/Output.php
@@ -0,0 +1,98 @@
+
+ *
+ * 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;
+
+use Composer\Pcre\Preg;
+use Composer\XdebugHandler\Process;
+
+class Output
+{
+ public const ENV_OUTPUT_INDENT = 'XDEBUG_HANDLER_TEST_INDENT';
+
+ /** @var bool */
+ private $isDisplay;
+
+ /** @var int */
+ private $indent = 0;
+
+ public function __construct(bool $display)
+ {
+ $this->isDisplay = $display;
+
+ if (!defined('STDOUT')) {
+ define('STDOUT', fopen('php://stdout', 'w'));
+ }
+
+ if (false === ($indent = getenv(self::ENV_OUTPUT_INDENT))) {
+ Process::setEnv(self::ENV_OUTPUT_INDENT, '0');
+ } else {
+ $this->indentIncrease();
+ }
+ }
+
+ public function __destruct()
+ {
+ $this->indentDecrease();
+ }
+
+ public function write(string $message, string $name): void
+ {
+ $prefix = sprintf('%s[%d]', $name, getmypid());
+ $color = $this->isDisplay && $name !== Logs::LOGGER_NAME;
+ $text = $this->format($prefix, $message, $color);
+
+ fwrite(STDOUT, $text);
+ fflush(STDOUT);
+ }
+
+ private function format(string $prefix, string $text, bool $color): string
+ {
+ if ($color) {
+ $prefix = sprintf("\033[33;33m%s\033[0m", $prefix);
+
+ if (Preg::isMatch('/(^working|^initial)/', $text, $matches)) {
+ $info = substr($text, 7);
+ $text = sprintf("\033[0;32m%s\033[0m", $matches[1]);
+ $text .= sprintf("\033[0;93m%s\033[0m", $info);
+ } else {
+ $text = sprintf("\033[0;32m%s\033[0m", $text);
+ }
+ }
+
+ $text = sprintf('%s %s%s', $prefix, $text, PHP_EOL);
+
+ if ($this->indent > 0) {
+ $prefix = str_repeat(chr(32), $this->indent);
+ $text = $prefix.$text;
+ }
+
+ 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/PhpExecutor.php b/tests/App/Framework/PhpExecutor.php
new file mode 100644
index 0000000..fafa079
--- /dev/null
+++ b/tests/App/Framework/PhpExecutor.php
@@ -0,0 +1,134 @@
+
+ *
+ * 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;
+
+use Composer\XdebugHandler\Process;
+
+class PhpExecutor
+{
+ public function run(string $script, PhpOptions $options, ?string $cwd = null): string
+ {
+ $content = null;
+ $params = array_merge([PHP_BINARY], $options->getPhpArgs());
+
+ if ($options->getStdin()) {
+ $content = $this->getContents($script);
+ } else {
+ $params[] = $script;
+ }
+
+ $params = array_merge($params, $options->getScriptArgs());
+
+ $this->setEnvs($options->getEnvs());
+
+ try {
+ $output = $this->execute($params, $cwd, $content, $options->getPassthru());
+ } finally {
+ $this->unsetEnvs($options->getEnvs());
+ }
+
+ return $output;
+ }
+
+ /**
+ *
+ * @param non-empty-list $args
+ */
+ private function execute(array $args, ?string $cwd, ?string $stdin, bool $passthru): string
+ {
+ $command = $this->getCommand($args);
+ $output = '';
+ $fds = [];
+
+ if ($stdin !== null) {
+ $fds[0] = ['pipe', 'r'];
+ }
+
+ if (!$passthru) {
+ $fds[1] = ['pipe', 'wb'];
+ }
+
+ $process = proc_open($command, $fds, $pipes, $cwd);
+
+ if (is_resource($process)) {
+ if ($stdin !== null) {
+ fwrite($pipes[0], $stdin);
+ fclose($pipes[0]);
+ }
+
+ if (!$passthru) {
+ $output = stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ }
+
+ $exitCode = proc_close($process);
+ }
+
+ return (string) $output;
+ }
+
+ /**
+ *
+ * @param non-empty-list $args
+ * @return non-empty-list|string
+ */
+ private function getCommand(array $args)
+ {
+ if (PHP_VERSION_ID >= 70400) {
+ return $args;
+ }
+
+ $command = Process::escapeShellCommand($args);
+
+ if (defined('PHP_WINDOWS_VERSION_BUILD')) {
+ // Outer quotes required on cmd string below PHP 8
+ $command = '"'.$command.'"';
+ }
+
+ return $command;
+ }
+
+ private function getContents(string $file): string
+ {
+ $result = file_get_contents($file);
+
+ if ($result !== false) {
+ return $result;
+ }
+
+ throw new \RuntimeException('Unable to read file: '.$file);
+ }
+
+ /**
+ *
+ * @param array $envs
+ */
+ private function setEnvs(array $envs): void
+ {
+ foreach ($envs as $name => $value) {
+ Process::setEnv($name, $value);
+ }
+ }
+
+ /**
+ *
+ * @param array $envs
+ */
+ private function unsetEnvs(array $envs): void
+ {
+ foreach ($envs as $name => $value) {
+ Process::setEnv($name);
+ }
+ }
+}
diff --git a/tests/App/Framework/PhpOptions.php b/tests/App/Framework/PhpOptions.php
new file mode 100644
index 0000000..9c7e383
--- /dev/null
+++ b/tests/App/Framework/PhpOptions.php
@@ -0,0 +1,99 @@
+
+ *
+ * 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 PhpOptions
+{
+ /** @var list */
+ private $phpArgs = [];
+ /** @var list */
+ private $scriptArgs = [];
+ /** @var array */
+ private $envs = [];
+ /** @var bool */
+ private $stdin = false;
+ /** @var bool */
+ private $passthru = false;
+
+ /**
+ * @return list
+ */
+ public function getPhpArgs(): array
+ {
+ return $this->phpArgs;
+ }
+
+ /**
+ * @return list
+ */
+ public function getScriptArgs(): array
+ {
+ return $this->scriptArgs;
+ }
+
+ /**
+ * @return array
+ */
+ public function getEnvs(): array
+ {
+ return $this->envs;
+ }
+
+ public function getStdin(): bool
+ {
+ return $this->stdin;
+ }
+
+ public function getPassthru(): bool
+ {
+ return $this->passthru;
+ }
+
+ /**
+ *
+ * @param string ...$phpArgs
+ */
+ public function addPhpArgs(...$phpArgs): void
+ {
+ foreach ($phpArgs as $item) {
+ $this->phpArgs[] = $item;
+ }
+ }
+
+ /**
+ *
+ * @param string ...$scriptArgs
+ */
+ public function addScriptArgs(...$scriptArgs): void
+ {
+ foreach ($scriptArgs as $item) {
+ $this->scriptArgs[] = $item;
+ }
+ }
+
+ public function addEnv(string $name, string $value): void
+ {
+ $this->envs[$name] = $value;
+ }
+
+ public function setPassthru(bool $value): void
+ {
+ $this->passthru = $value;
+ }
+
+ public function setStdin(bool $value): void
+ {
+ $this->stdin = $value;
+ }
+}
diff --git a/tests/App/Framework/Status.php b/tests/App/Framework/Status.php
new file mode 100644
index 0000000..b95a68f
--- /dev/null
+++ b/tests/App/Framework/Status.php
@@ -0,0 +1,227 @@
+
+ *
+ * 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;
+
+use Composer\XdebugHandler\XdebugHandler;
+
+/**
+ * @phpstan-import-type restartData from \Composer\XdebugHandler\PhpConfig
+ */
+class Status
+{
+ private const INVALID = 'not applicable';
+ private const MISSING = 'not available';
+
+ /** @var bool */
+ private $isDisplay;
+ /** @var bool */
+ private $showInis;
+ /** @var bool */
+ private $loaded = false;
+
+ public function __construct(bool $display, bool $showInis)
+ {
+ $this->isDisplay = $display;
+ $this->showInis = $showInis;
+ }
+
+ /**
+ *
+ * @param class-string $className
+ * @return list
+ */
+ public function getWorkingsStatus(string $className): array
+ {
+ $this->loaded = extension_loaded('xdebug');
+ $result = [];
+
+ // status
+ [$status, $info] = $this->getXdebugStatus();
+ $result[] = sprintf('[%s] %s', $status, $info);
+
+ if (!$this->isDisplay) {
+ return $result;
+ }
+
+ // active
+ $active = $this->getActive($className);
+ $result[] = sprintf('[active] %s', $active);
+
+ // skipped
+ $skipped = $this->getSkipped($className);
+ $result[] = sprintf('[skipped] %s', $skipped);
+
+ // envs
+ foreach($this->getEnvs() as $item) {
+ $result[] = sprintf('[env] %s', $item);
+ }
+
+ // inis
+ foreach($this->getInis($className) as $item) {
+ $result[] = sprintf('[ini] %s', $item);
+ }
+
+ // settings
+ foreach($this->getSettings($className) as $item) {
+ $result[] = sprintf('[settings] %s', $item);
+ }
+
+ return $result;
+ }
+
+ /**
+ *
+ * @return array{0: string, 1: string}
+ */
+ private function getXdebugStatus(): array
+ {
+ $status = $this->loaded ? 'XDEBUG' : 'NO XDEBUG';
+ $text = 'The Xdebug extension is';
+ $suffix = $this->loaded ? 'loaded' : 'not loaded';
+ $info = sprintf('%s %s', $text, $suffix);
+
+ return [$status, $info];
+ }
+
+ /**
+ *
+ * @param class-string $className
+ */
+ private function getActive(string $className): string
+ {
+ $active = $className::isXdebugActive();
+
+ return $active === true ? 'true' : 'false';
+ }
+
+ /**
+ *
+ * @param class-string $className
+ */
+ private function getSkipped(string $className): string
+ {
+ $version = $className::getSkippedVersion();
+
+ if ($version === '') {
+ return $this->loaded ? self::INVALID : self::MISSING;
+ }
+
+ return $version;
+ }
+
+ /**
+ * @return list
+ */
+ private function getEnvs(): array
+ {
+ $result = [];
+ $envs = ['PHPRC', 'PHP_INI_SCAN_DIR'];
+
+ foreach ($envs as $name) {
+ $format = '%s=%s';
+
+ if (false === ($env = getenv($name))) {
+ $env = 'unset';
+ $format = '%s %s';
+ } elseif ($env === '') {
+ $env = "''";
+ }
+
+ $result[] = sprintf($format, $name, $env);
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param class-string $className
+ * @return list
+ */
+ private function getInis(string $className): array
+ {
+ $iniFiles = $this->findIniFiles($className);
+
+ if ($iniFiles[0] === '') {
+ array_shift($iniFiles);
+ }
+
+ $count = count($iniFiles);
+
+ if ($this->showInis || $count === 1) {
+ return $iniFiles;
+ }
+
+ $message = sprintf("%d more ini files (show with '--inis' option)", $count - 1);
+
+ 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
+ * @return list
+ */
+ private function getSettings(string $className): array
+ {
+ /** @var restartData|null $settings */
+ $settings = $className::getRestartSettings();
+
+ if ($settings === null) {
+ return $this->loaded ? [self::INVALID] : [self::MISSING];
+ }
+
+ $settings['scannedInis'] = true === $settings['scannedInis'] ? 'true' : 'false';
+ $settings['scanDir'] = false === $settings['scanDir'] ? 'false' : $settings['scanDir'];
+ $settings['phprc'] = false === $settings['phprc'] ? 'false' : $settings['phprc'];
+ $settings['skipped'] = '' === $settings['skipped'] ? "''" : $settings['skipped'];
+
+ $iniCount = count($settings['inis']);
+ if ($iniCount === 1) {
+ $settings['inis'] = $settings['inis'][0];
+ } else {
+ $settings['inis'] = sprintf('(%d inis)', $iniCount);
+ }
+
+ $data = [];
+ 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
new file mode 100644
index 0000000..2f0be40
--- /dev/null
+++ b/tests/App/Helpers/FunctionalTestCase.php
@@ -0,0 +1,72 @@
+
+ *
+ * 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\App\Helpers;
+
+use Composer\XdebugHandler\Tests\App\Framework\AppRunner;
+use Composer\XdebugHandler\Tests\App\Framework\LogItem;
+use Composer\XdebugHandler\Tests\App\Framework\Logs;
+use PHPUnit\Framework\TestCase;
+
+abstract class FunctionalTestCase extends TestCase
+{
+ /** @var AppRunner */
+ protected $runner;
+
+ protected function setUp(): void
+ {
+ $scriptDir = dirname(__DIR__);
+ $this->runner = new AppRunner($scriptDir);
+ }
+
+ /**
+ * @param-out string $actual
+ */
+ protected function compareXdebugVersion(string $version, string $op, ?string &$actual): bool
+ {
+ $actual = (string) phpversion('xdebug');
+ return version_compare($actual, $version, $op);
+ }
+
+ protected function checkRestart(Logs $logs, ?int $childCount = null): void
+ {
+ $main = $logs->getValuesForProcess(1);
+ $child = $logs->getValuesForProcess(2);
+
+ self::assertCount(5, $main);
+
+ $count = $childCount === null ? 2 : $childCount;
+ self::assertCount($count, $child);
+
+ if ($childCount === null) {
+ $item = $logs->getItemFromList($child, 2);
+ self::assertStringContainsString('The Xdebug extension is not loaded', $item);
+ }
+ }
+
+ protected function checkNoRestart(Logs $logs, ?string $extraExpected = null): void
+ {
+ $main = $logs->getValuesForProcess(1);
+ $child = $logs->getValuesForProcess(2);
+
+ self::assertCount(3, $main);
+ self::assertCount(0, $child);
+
+ $item = $logs->getItemFromList($main, 3);
+ self::assertStringContainsString('No restart', $item);
+
+ if ($extraExpected !== null) {
+ self::assertStringContainsString($extraExpected, $item);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/App/Helpers/RestarterAllow.php b/tests/App/Helpers/RestarterAllow.php
new file mode 100644
index 0000000..f67e9ed
--- /dev/null
+++ b/tests/App/Helpers/RestarterAllow.php
@@ -0,0 +1,35 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace Composer\XdebugHandler\Tests\App\Helpers;
+
+use Composer\Pcre\Preg;
+use Composer\XdebugHandler\XdebugHandler;
+
+/**
+ * Restarts without xdebug only if not a help command
+ *
+ */
+
+class RestarterAllow extends XdebugHandler
+{
+ protected function requiresRestart(bool $default): bool
+ {
+ $argv = is_array($_SERVER['argv'] ?? []) ? $_SERVER['argv'] : [];
+ // @phpstan-ignore-next-line
+ $matches = Preg::grep('/^-h$|^(?:--)?help$/', $argv);
+ $required = count($matches) === 0;
+
+ return $default && $required;
+ }
+}
diff --git a/tests/App/Helpers/RestarterIni.php b/tests/App/Helpers/RestarterIni.php
new file mode 100644
index 0000000..00ca87d
--- /dev/null
+++ b/tests/App/Helpers/RestarterIni.php
@@ -0,0 +1,46 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace Composer\XdebugHandler\Tests\App\Helpers;
+
+use Composer\XdebugHandler\XdebugHandler;
+
+/**
+ * Restarts with phar.readonly=0 if needed, even if xdebug is not loaded
+ *
+ */
+
+class RestarterIni extends XdebugHandler
+{
+ /** @var bool */
+ private $required;
+
+ protected function requiresRestart(bool $default): bool
+ {
+ $this->required = (bool) ini_get('phar.readonly');
+
+ return $default || $this->required;
+
+ }
+
+ protected function restart(array $command): void
+ {
+ if ($this->required) {
+ $content = file_get_contents((string) $this->tmpIni);
+ $content .= 'phar.readonly = 0'.PHP_EOL;
+ file_put_contents((string) $this->tmpIni, $content);
+ }
+
+ parent::restart($command);
+ }
+}
diff --git a/tests/App/Helpers/RestarterMode.php b/tests/App/Helpers/RestarterMode.php
new file mode 100644
index 0000000..3241425
--- /dev/null
+++ b/tests/App/Helpers/RestarterMode.php
@@ -0,0 +1,81 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace Composer\XdebugHandler\Tests\App\Helpers;
+
+use Composer\Pcre\Preg;
+use Composer\XdebugHandler\XdebugHandler;
+use Composer\XdebugHandler\Process;
+
+/**
+ * Restarts with xdebug loaded in coverage mode, by uncommenting xdebug in the
+ * temporary ini file and setting the XDEBUG_MODE environment variable.
+ *
+ */
+
+class RestarterMode extends XdebugHandler
+{
+ protected function requiresRestart(bool $default): bool
+ {
+ if ($default) {
+ $version = (string) phpversion('xdebug');
+
+ if (version_compare($version, '3.1', '>=')) {
+ $modes = xdebug_info('mode');
+ return !in_array('coverage', $modes, true);
+ }
+
+ // See if xdebug.mode is supported in this version
+ $iniMode = ini_get('xdebug.mode');
+ if ($iniMode === false) {
+ return false;
+ }
+
+ // Environment value wins but cannot be empty
+ $envMode = (string) getenv('XDEBUG_MODE');
+ if ($envMode !== '') {
+ $mode = $envMode;
+ } else {
+ $mode = $iniMode !== '' ? $iniMode : 'off';
+ }
+
+ // An empty comma-separated list is treated as mode 'off'
+ if (Preg::isMatch('/^,+$/', str_replace(' ', '', $mode))) {
+ $mode = 'off';
+ }
+
+ $modes = explode(',', str_replace(' ', '', $mode));
+ return !in_array('coverage', $modes, true);
+ }
+
+ return false;
+ }
+
+ protected function restart(array $command): void
+ {
+ // uncomment last xdebug line
+ $regex = '/^;\s*(zend_extension\s*=.*xdebug.*)$/mi';
+ $content = (string) file_get_contents((string) $this->tmpIni);
+
+ if (Preg::isMatchAll($regex, $content, $matches)) {
+ $index = count($matches[1]) -1;
+ $line = $matches[1][$index];
+ $content .= $line.PHP_EOL;
+ }
+
+ file_put_contents((string) $this->tmpIni, $content);
+ Process::setEnv('XDEBUG_MODE', 'coverage');
+
+ parent::restart($command);
+ }
+}
diff --git a/tests/App/README.md b/tests/App/README.md
new file mode 100644
index 0000000..15a0539
--- /dev/null
+++ b/tests/App/README.md
@@ -0,0 +1,126 @@
+# Functional Test Scripts
+
+This folder contains the PHP scripts that produce the output for the functional tests.
+
+They can be run from the command-line to provide a visual representation of xdebug-handler's inner
+workings.
+
+## Usage
+
+There are two core scripts, `app-basic` which includes xdebug-handler, and `app-plain` which
+does not.
+
+```sh
+cd tests/App
+
+php app-basic.php --display
+php app-plain.php --display
+```
+
+The `--display` options provides colorized output with a detailed list of xdebug-handler values:
+
+* [active] - from isXdebugActive()
+* [skipped] - from getSkippedVersion()
+* [env] - environment variables (PHPRC and PHP_INI_SCAN_DIR)
+* [ini] - from getAllIniFiles(). Use the `--inis` options to show them all.
+* [settings] - from getRestartSettings()
+
+These values are obtained after any restart has happened.
+
+## Scripts
+
+The following scripts are in addition to the core scripts (which are used by some of these).
+### app-extend-allow
+
+Demonstrates how an extended class can decide whether to restart or not.
+
+```sh
+cd tests/App
+
+# restarts if no help command or option
+php app-extend-allow.php --display
+
+# no restart with -h, --help or help
+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.
+
+```sh
+cd tests/App
+
+php -dphar.read_only=1 app-extend-ini.php --display
+```
+
+### app-extend-mode
+
+Demonstrates how xdebug can be restarted in a different mode, in this example `xdebug.mode=coverage`.
+
+```sh
+cd tests/App
+
+php -dxdebug.mode=develop app-extend-mode.php --display
+```
+
+### app-persistent
+
+Demonstrates the use of persistent settings (to remove xdebug from all sub-processes) and PhpConfig
+to enable xdebug when required. The following sub-processes are called:
+
+ * app-plain.php (xdebug not loaded)
+ * app-plain.php with original settings (xdebug will be loaded)
+ * app-other.php (xdebug not loaded)
+```sh
+php app-persistent.php --display
+```
+
+### app-prepend
+
+Demonstrates the use of an auto prepend file to remove xdebug from a script. Note that either
+`app-basic` or `app-plain` can be used here.
+
+```sh
+cd tests/App
+
+php -dauto_prepend_file=app-prepend.php app-plain.php --display
+```
+
+The `auto_prepend_file` ini setting from the command-line is picked up when the ini content is
+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
+```
+
+### app-stdin
+
+Included here for demonstration purposes only.
+
+Uses xdebug-handler from stdin to remove xdebug from a script. Note that either `app-basic` or
+`app-plain` can be used here.
+
+```sh
+cd tests/App
+
+# Unixy
+cat app-stdin.php | php -- app-plain.php --display
+
+# Windows
+type app-stdin.php | php -- app-plain.php --display
+```
+
+While this works in simple scenarios, it is not recommended because the restart settings are not
+available. This is due to a different script restarting, thus preventing `app-stdin` from setting
+the restart environment variable.
+
+Additionally _getAllIniFiles()_ returns the temp ini used in the
+restart, because the restarting script is not looking for its own named environment variable.
diff --git a/tests/App/app-basic.php b/tests/App/app-basic.php
new file mode 100644
index 0000000..d8fee4b
--- /dev/null
+++ b/tests/App/app-basic.php
@@ -0,0 +1,33 @@
+
+ *
+ * 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\XdebugHandler;
+
+/**
+ * Restarts without xdebug
+ *
+ * Usage: php app.php
+ */
+
+$app = new AppHelper(__FILE__);
+
+$app->write('start');
+
+$xdebug = $app->getXdebugHandler();
+$xdebug->check();
+
+$app->writeXdebugStatus();
+$app->write('finish');
diff --git a/tests/App/app-extend-allow.php b/tests/App/app-extend-allow.php
new file mode 100644
index 0000000..425e83e
--- /dev/null
+++ b/tests/App/app-extend-allow.php
@@ -0,0 +1,35 @@
+
+ *
+ * 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\Helpers\RestarterAllow;
+use Composer\XdebugHandler\Tests\App\Framework\AppHelper;
+use Composer\XdebugHandler\XdebugHandler;
+
+/**
+ * No restart if a help command (-h, --help, help)
+ *
+ * Usage: php app-extend-help.php -h
+ *
+ */
+
+$app = new AppHelper(__FILE__);
+
+$app->write('start');
+
+$xdebug = $app->getXdebugHandler(RestarterAllow::class);
+$xdebug->check();
+
+$app->writeXdebugStatus();
+$app->write('finish');
diff --git a/tests/App/app-extend-ini.php b/tests/App/app-extend-ini.php
new file mode 100644
index 0000000..ffdac27
--- /dev/null
+++ b/tests/App/app-extend-ini.php
@@ -0,0 +1,37 @@
+
+ *
+ * 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\Helpers\RestarterIni;
+use Composer\XdebugHandler\Tests\App\Framework\AppHelper;
+use Composer\XdebugHandler\XdebugHandler;
+
+/**
+ * Restarts without xdebug with phar.read_only=0, even if xdebug is not loaded
+ *
+ * Usage: php -dphar.read_only=1 app-extend-ini.php
+ */
+
+$app = new AppHelper(__FILE__);
+
+$app->write('start');
+
+$setting = (int) ini_get('phar.readonly');
+$app->write('initial phar.readonly='.$setting);
+
+$xdebug = $app->getXdebugHandler(RestarterIni::class);
+$xdebug->check();
+
+$app->writeXdebugStatus();
+$app->write('finish');
diff --git a/tests/App/app-extend-mode.php b/tests/App/app-extend-mode.php
new file mode 100644
index 0000000..4769356
--- /dev/null
+++ b/tests/App/app-extend-mode.php
@@ -0,0 +1,34 @@
+
+ *
+ * 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\Helpers\RestarterMode;
+use Composer\XdebugHandler\Tests\App\Framework\AppHelper;
+use Composer\XdebugHandler\XdebugHandler;
+
+/**
+ * Restarts with xdebug and xdebug.mode=coverage
+ *
+ * Usage: php -dxdebug.mode=develop app-extend-mode.php
+ */
+
+$app = new AppHelper(__FILE__);
+
+$app->write('start');
+
+$xdebug = $app->getXdebugHandler(RestarterMode::class);
+$xdebug->check();
+
+$app->writeXdebugStatus();
+$app->write('finish');
diff --git a/tests/App/app-persistent.php b/tests/App/app-persistent.php
new file mode 100644
index 0000000..cb1a8d3
--- /dev/null
+++ b/tests/App/app-persistent.php
@@ -0,0 +1,59 @@
+
+ *
+ * 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\XdebugHandler;
+use Composer\XdebugHandler\PhpConfig;
+
+/**
+ * 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)
+ *
+ * Usage: php app-persistent.php
+ */
+
+$app = new AppHelper(__FILE__);
+
+$app->write('start');
+
+$xdebug = $app->getXdebugHandler(null, ['persistent' => '']);
+$xdebug->check();
+
+$app->writeXdebugStatus();
+
+// Simple call to app-plain (xdebug should not be loaded)
+$app->write('CALL app-plain.php');
+$app->write('- if we are in a restart, xdebug should not be loaded');
+$app->runScript('app-plain.php');
+
+// app-plain with xdebug
+$app->write('CALL app-plain.php with original settings');
+$app->write('- if we are in a restart, xdebug will be loaded');
+
+$phpConfig = new PhpConfig;
+$phpConfig->useOriginal();
+$app->runScript('app-plain.php');
+
+// restore persistent settings
+$phpConfig->usePersistent();
+
+// Simple call to app-basic (xdebug should not be loaded)
+$app->write('CALL app-basic.php');
+$app->write('- if we are in a restart, xdebug should not be loaded');
+$app->runScript('app-basic.php');
+
+$app->write('finish');
diff --git a/tests/App/app-plain.php b/tests/App/app-plain.php
new file mode 100644
index 0000000..664e974
--- /dev/null
+++ b/tests/App/app-plain.php
@@ -0,0 +1,28 @@
+
+ *
+ * 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;
+
+/**
+ * A plain php script that does not include xdebug-handler
+ *
+ * Usage: php app-none.php
+ */
+
+$app = new AppHelper(__FILE__);
+
+$app->write('start');
+$app->writeXdebugStatus();
+$app->write('finish');
diff --git a/tests/App/app-prepend.php b/tests/App/app-prepend.php
new file mode 100644
index 0000000..fa6f172
--- /dev/null
+++ b/tests/App/app-prepend.php
@@ -0,0 +1,49 @@
+
+ *
+ * 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\XdebugHandler;
+
+/**
+ * Restarts without xdebug using an autoprepend file
+ *
+ * Usage: php -dauto_prepend_file=app-prepend.php app-plain.php
+ *
+ * The auto_prepend_file ini setting from the command-line is picked up when the
+ * 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
+ */
+
+$app = new AppHelper(__FILE__);
+
+$app->write('start');
+
+$mainScript = $app->getServerArgv0();
+$app->write('initial argv0 '.$mainScript);
+
+// Set mainScript. Has no effect in restarted process
+$settings = null;
+if ($mainScript === 'Standard input code') {
+ $settings = ['mainScript' => '--'];
+}
+
+$xdebug = $app->getXdebugHandler(null, $settings);
+$xdebug->check();
+
+$app->writeXdebugStatus();
+$app->write('finish');
diff --git a/tests/App/app-stdin.php b/tests/App/app-stdin.php
new file mode 100644
index 0000000..716ef6b
--- /dev/null
+++ b/tests/App/app-stdin.php
@@ -0,0 +1,47 @@
+
+ *
+ * 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\XdebugHandler;
+
+/**
+ * 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
+ *
+ */
+
+$app = new AppHelper(__FILE__);
+
+$app->write('start');
+
+$argv = $app->getServerArgv();
+$mainScript = $argv[0];
+$app->write('working: argv0 '. $mainScript);
+
+// Set mainScript. Has no effect in restarted process
+$settings = null;
+if ($mainScript === 'Standard input code' && isset($argv[1])) {
+ $settings = ['mainScript' => $argv[1]];
+ // @phpstan-ignore-next-line
+ unset($_SERVER['argv'][1]);
+}
+
+$xdebug = $app->getXdebugHandler(null, $settings);
+$xdebug->check();
+
+$app->writeXdebugStatus();
+$app->write('finish');
diff --git a/tests/AppNoRestartTest.php b/tests/AppNoRestartTest.php
new file mode 100644
index 0000000..ab4c757
--- /dev/null
+++ b/tests/AppNoRestartTest.php
@@ -0,0 +1,95 @@
+
+ *
+ * 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\PhpOptions;
+use Composer\XdebugHandler\Tests\App\Helpers\FunctionalTestCase;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @group functional
+ */
+class AppNoRestartTest extends FunctionalTestCase
+{
+ /**
+ * Tests no restart when env allow is truthy
+ *
+ * @requires extension xdebug
+ */
+ 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);
+ }
+
+ /**
+ * Tests no restart when xdebug.mode is off
+ *
+ * @requires extension xdebug
+ */
+ public function testNoRestartWhenModeOff(): void
+ {
+ if ($this->compareXdebugVersion('3.1', '<', $version)) {
+ self::markTestSkipped('Not supported in xdebug version '.$version);
+ }
+
+ $script = 'app-basic.php';
+
+ $options = new PhpOptions();
+ $options->addPhpArgs('-dxdebug.mode=off');
+
+ $logs = $this->runner->run($script, $options);
+ $this->checkNoRestart($logs, 'Allowed by xdebug.mode');
+ }
+
+ /**
+ * Tests no restart when proc_open is disabled
+ *
+ * @requires extension xdebug
+ */
+ function testNoRestartWhenConfigError(): void
+ {
+ $script = 'app-basic.php';
+
+ $options = new PhpOptions();
+ $options->addPhpArgs('-ddisable_functions=proc_open');
+
+ $logs = $this->runner->run($script, $options);
+ $this->checkNoRestart($logs, 'proc_open function is disabled');
+ }
+
+ /**
+ * Tests no restart when allowed by an application
+ *
+ * @requires extension xdebug
+ */
+ function testNoRestartWhenAllowedByApplication(): void
+ {
+ $script = 'app-extend-allow.php';
+
+ $options = new PhpOptions();
+ $options->addScriptArgs('--help');
+
+ $logs = $this->runner->run($script, $options);
+ $this->checkNoRestart($logs, 'Allowed by application');
+ }
+}
diff --git a/tests/AppRestartTest.php b/tests/AppRestartTest.php
new file mode 100644
index 0000000..ab47f57
--- /dev/null
+++ b/tests/AppRestartTest.php
@@ -0,0 +1,181 @@
+
+ *
+ * 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\LogItem;
+use Composer\XdebugHandler\Tests\App\Framework\PhpOptions;
+use Composer\XdebugHandler\Tests\App\Helpers\FunctionalTestCase;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @group functional
+ */
+class AppRestartTest extends FunctionalTestCase
+{
+ /**
+ *
+ * @requires extension xdebug
+ */
+ public function testRestart(): void
+ {
+ $script = 'app-basic.php';
+
+ $logs = $this->runner->run($script);
+ $this->checkRestart($logs);
+ }
+
+ /**
+ *
+ * @requires extension xdebug
+ */
+ public function testRestartWithPrependFile(): void
+ {
+ $script = 'app-plain.php';
+
+ $options = new PhpOptions();
+ $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);
+ }
+
+ /**
+ *
+ * @requires extension xdebug
+ */
+ public function testRestartWithPrependFileStdin(): void
+ {
+ $script = 'app-plain.php';
+
+ $options = new PhpOptions();
+ $options->addPhpArgs('-dauto_prepend_file=app-prepend.php');
+ $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);
+ }
+
+ /**
+ *
+ * @requires extension xdebug
+ */
+ public function testRestartWithStdin(): void
+ {
+ $script = 'app-stdin.php';
+
+ $options = new PhpOptions();
+ $options->addPhpArgs('--');
+ $options->addScriptArgs('app-plain.php');
+ $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);
+ }
+
+ /**
+ *
+ * @requires extension xdebug
+ */
+ public function testRestartWithIniChange(): void
+ {
+ $script = 'app-extend-ini.php';
+
+ $options = new PhpOptions();
+ $options->addPhpArgs('-dphar.read_only=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);
+ }
+
+ /**
+ *
+ * @requires extension xdebug
+ */
+ public function testRestartWithXdebug(): void
+ {
+ $version = (string) phpversion('xdebug');
+
+ if (version_compare($version, '3.0', '<')) {
+ self::markTestSkipped('Not supported in xdebug version '.$version);
+ }
+
+ $script = 'app-extend-mode.php';
+
+ $options = new PhpOptions();
+ $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);
+ }
+
+ /**
+ *
+ * @requires extension xdebug
+ */
+ public function testRestartWithProcessPersistent(): 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);
+ }
+}