From b3932674dd970f26b66b4c92eb657a7872a83b0f Mon Sep 17 00:00:00 2001 From: magdev Date: Sat, 2 May 2026 01:08:09 +0200 Subject: [PATCH] Phase 1 sub-commit 3: bridge:doctor console command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Console command bridge:doctor surfaces actionable hints for env / wiring problems so first-run failures aren't a "connection refused" mystery. Checks PHP version, ext-curl, ext-json, the Publisher service is wired (meaning BridgeBundle loaded), and the BRIDGE_TOKEN / MERCURE_URL / MERCURE_PUBLISHER_JWT_KEY / MERCURE_SUBSCRIBER_JWT_KEY env vars. With --connect, also probes the configured URL via plain stream context (no extra dep) and fails the run when unreachable. CommandTester suite covers green path, missing-env path, and an unreachable-URL probe — 11 tests, 32 assertions, all green. Skeleton's Makefile target stays a TBD until sub-commit 6 stands up the Symfony app the command runs from. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../php/src/Command/BridgeDoctorCommand.php | 136 ++++++++++++++++++ .../tests/Command/BridgeDoctorCommandTest.php | 88 ++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 framework/php/src/Command/BridgeDoctorCommand.php create mode 100644 framework/php/tests/Command/BridgeDoctorCommandTest.php diff --git a/framework/php/src/Command/BridgeDoctorCommand.php b/framework/php/src/Command/BridgeDoctorCommand.php new file mode 100644 index 0000000..db133c5 --- /dev/null +++ b/framework/php/src/Command/BridgeDoctorCommand.php @@ -0,0 +1,136 @@ +addOption('connect', null, InputOption::VALUE_NONE, 'Probe the backend over HTTP after the static checks.'); + $this->addOption('url', null, InputOption::VALUE_REQUIRED, 'URL to probe when --connect is given.', 'http://127.0.0.1:8765/healthz'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('php-qml bridge — readiness checks'); + + $checks = [ + ['PHP version >= 8.3', + PHP_VERSION_ID >= 80300, + 'Upgrade PHP to 8.3 or newer; the bundle requires it.'], + ['ext-curl available', + extension_loaded('curl'), + 'Install the PHP curl extension.'], + ['ext-json available', + extension_loaded('json'), + 'Install the PHP json extension.'], + ['Publisher service wired', + $this->publisher instanceof Publisher, + 'Bundle services failed to load — register PhpQml\\Bridge\\BridgeBundle in config/bundles.php.'], + ['BRIDGE_TOKEN env set', + $this->bridgeToken !== '', + 'Set BRIDGE_TOKEN in .env.local; the Qt host expects this as the bearer token.'], + ['MERCURE_URL env set', + $this->mercureUrl !== '', + 'Set MERCURE_URL in .env, e.g. http://127.0.0.1:8765/.well-known/mercure.'], + ['MERCURE_PUBLISHER_JWT_KEY env set', + $this->mercurePublisherKey !== '', + 'Set MERCURE_PUBLISHER_JWT_KEY in .env.local; mercure-bundle uses it to sign publish requests.'], + ['MERCURE_SUBSCRIBER_JWT_KEY env set', + $this->mercureSubscriberKey !== '', + 'Set MERCURE_SUBSCRIBER_JWT_KEY in .env.local; or rely on the Caddy `anonymous` directive in dev mode.'], + ]; + + $rows = []; + $allPass = true; + foreach ($checks as [$label, $ok, $hint]) { + $rows[] = [ + $ok ? 'PASS' : 'FAIL', + $label, + $ok ? '' : $hint, + ]; + $allPass = $allPass && $ok; + } + $io->table(['', 'Check', 'Hint'], $rows); + + if ($input->getOption('connect')) { + $url = (string) $input->getOption('url'); + $io->section("Probing {$url}"); + $probe = $this->probe($url); + if ($probe['ok']) { + $io->success("Backend reachable (HTTP {$probe['status']})."); + } else { + $io->error("Backend probe failed: {$probe['detail']}"); + $allPass = false; + } + } + + if ($allPass) { + $io->success('All checks passed. Bridge is ready.'); + return Command::SUCCESS; + } + + $io->warning('Some checks failed — fix the items above before running the bridge.'); + return Command::FAILURE; + } + + /** + * @return array{ok: bool, status?: int, detail: string} + */ + private function probe(string $url, float $timeoutSeconds = 2.0): array + { + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => $timeoutSeconds, + 'ignore_errors' => true, + ], + ]); + $body = @file_get_contents($url, false, $ctx); + if ($body === false) { + $err = error_get_last()['message'] ?? 'unknown error'; + return ['ok' => false, 'detail' => $err]; + } + $statusLine = $http_response_header[0] ?? ''; + preg_match('#HTTP/\S+\s+(\d+)#', $statusLine, $m); + $status = (int) ($m[1] ?? 0); + return [ + 'ok' => $status === 200, + 'status' => $status, + 'detail' => $status === 200 ? '' : "expected 200, got {$status}", + ]; + } +} diff --git a/framework/php/tests/Command/BridgeDoctorCommandTest.php b/framework/php/tests/Command/BridgeDoctorCommandTest.php new file mode 100644 index 0000000..3d2a863 --- /dev/null +++ b/framework/php/tests/Command/BridgeDoctorCommandTest.php @@ -0,0 +1,88 @@ +makePublisher(), + bridgeToken: 'devtoken', + mercureUrl: 'http://127.0.0.1:8765/.well-known/mercure', + mercurePublisherKey: 'devkey', + mercureSubscriberKey: 'devkey', + ); + $tester = new CommandTester($command); + + $exit = $tester->execute([]); + + self::assertSame(0, $exit); + $display = $tester->getDisplay(); + self::assertStringContainsString('All checks passed', $display); + self::assertStringNotContainsString('FAIL', $display); + } + + public function testFailsAndShowsHintsWhenEnvIsMissing(): void + { + $command = new BridgeDoctorCommand( + publisher: $this->makePublisher(), + bridgeToken: '', + mercureUrl: '', + mercurePublisherKey: '', + mercureSubscriberKey: '', + ); + $tester = new CommandTester($command); + + $exit = $tester->execute([]); + + self::assertSame(1, $exit); + $display = $tester->getDisplay(); + self::assertStringContainsString('BRIDGE_TOKEN env set', $display); + self::assertStringContainsString('MERCURE_URL env set', $display); + self::assertStringContainsString('Set BRIDGE_TOKEN in .env.local', $display); + self::assertStringContainsString('Some checks failed', $display); + } + + public function testConnectOptionFailsClosedAgainstAnUnreachableUrl(): void + { + $command = new BridgeDoctorCommand( + publisher: $this->makePublisher(), + bridgeToken: 'devtoken', + mercureUrl: 'http://127.0.0.1:8765/.well-known/mercure', + mercurePublisherKey: 'devkey', + mercureSubscriberKey: 'devkey', + ); + $tester = new CommandTester($command); + + // Port 1 is reserved and refuses connections — the probe must fail. + $exit = $tester->execute(['--connect' => true, '--url' => 'http://127.0.0.1:1/healthz']); + + self::assertSame(1, $exit); + self::assertStringContainsString('Backend probe failed', $tester->getDisplay()); + } + + private function makePublisher(): Publisher + { + return new Publisher(new class implements HubInterface { + public function getUrl(): string { return ''; } + public function getPublicUrl(): string { return ''; } + public function getProvider(): \Symfony\Component\Mercure\Jwt\TokenProviderInterface + { throw new \LogicException(); } + public function getFactory(): ?\Symfony\Component\Mercure\Jwt\TokenFactoryInterface + { return null; } + public function publish(Update $update): string { return ''; } + }); + } +}