tty_enabled = $tty; return $this; } public function disableOutput(): static { $this->output_disabled = true; return $this; } public function run(callable|null $callback = null, array $env = []): int { if ($this->term_signal !== null) { throw new ProcessSignaledException($this); } return $this->result; } public function getTermSignal(): int { return $this->term_signal ?? 0; } public function isRunning(): bool { return $this->running; } public function signal(int $signal): static { if (!$this->running) { throw new ProcessLogicException('Cannot send signal on a non running process.'); } $this->received_signal = $signal; return $this; } } it('registers every executable legacy CLI script exactly once', function (): void { $root = dirname(__DIR__, 4); $non_commands = ['cacti', 'index']; $scripts = array_map( static fn (string $path): string => basename($path, '.php'), glob($root . '/cli/*.php') ?: [] ); $mapped = array_keys(LegacyCommandMap::commands()); $missing = array_values(array_diff($mapped, $scripts)); $unexpected = array_values(array_diff($scripts, $mapped, $non_commands)); expect($missing)->toBe([], 'Every mapped command must name an existing cli/*.php script.') ->and($unexpected)->toBe([], 'Classify new non-command files in $non_commands, or add commands to LegacyCommandMap.') ->and(array_unique(array_values(LegacyCommandMap::commands()))) ->toHaveCount(count($mapped)); }); it('lists commands without bootstrapping the database', function (): void { $root = dirname(__DIR__, 4); $application = new CactiApplication($root); $application->setAutoExit(false); $tester = new ApplicationTester($application); $status = $tester->run(['command' => 'list', '--format' => 'json']); $commands = json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR)['commands']; $names = array_column($commands, 'name'); expect($status)->toBe(0) ->and($names)->toContain('device:add', 'database:audit', 'poller:reindex', 'rrd:resize'); }); it('reports an unknown version when the version file is unavailable', function (): void { $application = new CactiApplication('/path/that/does/not/exist'); expect($application->getVersion())->toBe('unknown'); }); it('leaves unknown commands to Symfony after legacy lookup fails', function (): void { $application = new CactiApplication(dirname(__DIR__, 4)); $application->setAutoExit(false); $application->setCatchExceptions(false); expect(fn (): int => $application->run(new RawArgvInput(['bin/cacti', 'not:a:command']), new BufferedOutput())) ->toThrow(Symfony\Component\Console\Exception\CommandNotFoundException::class); }); it('hands a terminal directly to a legacy process', function (): void { $process = new CactiControllableLegacyProcess(17); $command = new LegacyScriptCommand( 'probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console', static fn (array $command): Process => $process, static fn (): bool => true, false ); expect($command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput()))->toBe(17) ->and($process->tty_enabled)->toBeTrue() ->and($process->output_disabled)->toBeFalse(); }); it('maps a signaled legacy process to the shell exit status', function (): void { $process = new CactiControllableLegacyProcess(term_signal: SIGKILL); $command = new LegacyScriptCommand( 'probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console', static fn (array $command): Process => $process, static fn (): bool => false, false ); expect($command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput()))->toBe(128 + SIGKILL); }); it('relays registered termination signals only while the child is running', function (): void { $process = new CactiControllableLegacyProcess(); $handlers = []; $command = new LegacyScriptCommand( 'probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console', static fn (array $command): Process => $process, static fn (): bool => false, static function (int $signal, callable $handler) use (&$handlers): void { $handlers[$signal] = $handler; } ); $command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput()); $process->running = true; $handlers[SIGTERM](SIGTERM); expect($process->received_signal)->toBe(SIGTERM) ->and($handlers)->toHaveKeys([SIGTERM, SIGINT, SIGHUP]); $process->running = false; $process->received_signal = null; $handlers[SIGINT](SIGINT); expect($process->received_signal)->toBeNull(); }); it('preserves the child status when a signal arrives before process startup', function (): void { $process = new CactiControllableLegacyProcess(17); $command = new LegacyScriptCommand( 'probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console', static fn (array $command): Process => $process, static fn (): bool => false, static function (int $signal, callable $handler): void { if ($signal === SIGTERM) { $handler($signal); } } ); expect($command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput()))->toBe(17) ->and($process->received_signal)->toBeNull(); }); it('forwards raw legacy arguments and preserves the exit status', function (): void { $root = dirname(__DIR__, 3) . '/fixtures/Console'; $application = new Application('test'); $application->setAutoExit(false); $application->setCatchExceptions(false); $application->add(new LegacyScriptCommand('probe:run', 'probe', $root)); $input = new RawArgvInput(['bin/cacti', 'probe:run', '--arbitrary=value', '-x', 'plain']); $output = new BufferedOutput(); $status = $application->run($input, $output); expect($status)->toBe(23) ->and($output->fetch())->toBe('["--arbitrary=value","-x","plain"]probe-error'); }); it('routes child stderr separately for console outputs and accepts aliases', function (): void { $root = dirname(__DIR__, 3) . '/fixtures/Console'; $application = new Application('test'); $application->setAutoExit(false); $application->setCatchExceptions(false); $application->add(new LegacyScriptCommand('probe:run', 'probe', $root)); $output = new ConsoleOutput(decorated: false); $errorOutput = new BufferedOutput(); $output->setErrorOutput($errorOutput); $status = $application->run( new RawArgvInput(['bin/cacti', 'probe', 'alias-value']), $output ); expect($status)->toBe(23) ->and($errorOutput->fetch())->toBe('probe-error'); }); it('forwards arguments when the command name is abbreviated', function (): void { $root = dirname(__DIR__, 3) . '/fixtures/Console'; $application = new Application('test'); $application->setAutoExit(false); $application->setCatchExceptions(false); $application->add(new LegacyScriptCommand('probe:run', 'probe', $root)); $output = new BufferedOutput(); $status = $application->run( new RawArgvInput(['bin/cacti', 'pro:r', '--arbitrary=value', 'plain']), $output ); expect($status)->toBe(23) ->and($output->fetch())->toBe('["--arbitrary=value","plain"]probe-error'); }); it('leaves --help, -h, --version and -V to the legacy script', function (string $flag): void { $application = new CactiApplication(dirname(__DIR__, 4)); $application->setAutoExit(false); $application->setCatchExceptions(false); $application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console')); $output = new BufferedOutput(); $status = $application->run(new RawArgvInput(['bin/cacti', 'probe:run', $flag]), $output); expect($status)->toBe(23) ->and($output->fetch())->toBe(sprintf('["%s"]probe-error', $flag)); })->with(['--help', '-h', '--version', '-V']); it('forwards a flag written before the command name', function (string $flag): void { $application = new CactiApplication(dirname(__DIR__, 4)); $application->setAutoExit(false); $application->setCatchExceptions(false); $application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console')); $output = new BufferedOutput(); // getFirstArgument() skips leading options, so the command dispatches with // the flag ahead of its name. Slicing only forward handed the script an // empty argv, and a script whose flags are all optional then ran its // default path. $status = $application->run(new RawArgvInput(['bin/cacti', $flag, 'probe:run']), $output); expect($status)->toBe(23) ->and($output->fetch())->toBe(sprintf('["%s"]probe-error', $flag)); })->with(['--help', '-h', '--version', '-V']); it('preserves the order of flags on both sides of the command name', function (): void { $application = new CactiApplication(dirname(__DIR__, 4)); $application->setAutoExit(false); $application->setCatchExceptions(false); $application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console')); $output = new BufferedOutput(); $status = $application->run( new RawArgvInput(['bin/cacti', '-v', 'probe:run', '--arbitrary=value', 'plain']), $output ); expect($status)->toBe(23) ->and($output->fetch())->toBe('["-v","--arbitrary=value","plain"]probe-error'); }); it('still answers --version and --help for the application itself', function (): void { $application = new CactiApplication(dirname(__DIR__, 4)); $application->setAutoExit(false); $application->setCatchExceptions(false); $output = new BufferedOutput(); expect($application->run(new RawArgvInput(['bin/cacti', '--version']), $output))->toBe(0) ->and($output->fetch())->toStartWith('Cacti'); }); it('does not swallow legacy output when -q is passed through', function (): void { $application = new CactiApplication(dirname(__DIR__, 4)); $application->setAutoExit(false); $application->setCatchExceptions(false); $application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console')); $output = new BufferedOutput(); $status = $application->run(new RawArgvInput(['bin/cacti', 'probe:run', '-q', 'plain']), $output); expect($status)->toBe(23) ->and($output->fetch())->toBe('["-q","plain"]probe-error'); }); it('streams large legacy output without retaining it in the parent', function (): void { $root = dirname(__DIR__, 3) . '/fixtures/Console'; $application = new Application('test'); $application->setAutoExit(false); $application->setCatchExceptions(false); $application->add(new LegacyScriptCommand('bulk:run', 'bulk', $root)); $output = new BufferedOutput(); $status = $application->run(new RawArgvInput(['bin/cacti', 'bulk:run', '20000']), $output); // 20000 lines is past the 1MB php://temp threshold Process spools at. expect($status)->toBe(0) ->and(strlen($output->fetch()))->toBe(20000 * 100); }); it('reports a killed legacy script as the shell does', function (): void { if (!function_exists('pcntl_fork') || !function_exists('posix_kill') || !defined('SIGKILL')) { $this->markTestSkipped('pcntl and posix signal support are required.'); } $root = dirname(__DIR__, 3) . '/fixtures/Console'; $runner = dirname(__DIR__, 3) . '/fixtures/Console/kill_runner.php'; $descriptor = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $process = proc_open([PHP_BINARY, $runner, $root], $descriptor, $pipes); expect($process)->toBeResource(); $stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); proc_close($process); // 128 + SIGKILL, the status a shell reports for the same death. expect(trim($stdout))->toBe('137') ->and($stderr)->not->toContain('ProcessSignaledException'); }); it('runs the legacy script in the directory the caller was in', function (): void { $root = dirname(__DIR__, 3) . '/fixtures/Console'; $original = getcwd(); $elsewhere = sys_get_temp_dir(); try { chdir($elsewhere); $application = new Application('test'); $application->setAutoExit(false); $application->setCatchExceptions(false); $application->add(new LegacyScriptCommand('pwd:run', 'pwd', $root)); $output = new BufferedOutput(); $application->run(new RawArgvInput(['bin/cacti', 'pwd:run']), $output); // A relative path argument has to mean what it would have meant to // `php cli/