Phase 1 sub-commit 3: bridge:doctor console command
All checks were successful
CI / Quality (push) Successful in 5s
All checks were successful
CI / Quality (push) Successful in 5s
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) <noreply@anthropic.com>
This commit is contained in:
136
framework/php/src/Command/BridgeDoctorCommand.php
Normal file
136
framework/php/src/Command/BridgeDoctorCommand.php
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge\Command;
|
||||||
|
|
||||||
|
use PhpQml\Bridge\Publisher;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Readiness checks for the php-qml bridge. Surfaces actionable hints
|
||||||
|
* for missing env / wiring instead of "connection refused" later.
|
||||||
|
*
|
||||||
|
* See PLAN.md §8 (*Lifecycle and inspection commands*).
|
||||||
|
*/
|
||||||
|
#[AsCommand(name: 'bridge:doctor', description: 'Run readiness checks for the php-qml bridge.')]
|
||||||
|
final class BridgeDoctorCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Publisher $publisher,
|
||||||
|
#[Autowire('%env(default::BRIDGE_TOKEN)%')]
|
||||||
|
private readonly string $bridgeToken,
|
||||||
|
#[Autowire('%env(default::MERCURE_URL)%')]
|
||||||
|
private readonly string $mercureUrl,
|
||||||
|
#[Autowire('%env(default::MERCURE_PUBLISHER_JWT_KEY)%')]
|
||||||
|
private readonly string $mercurePublisherKey,
|
||||||
|
#[Autowire('%env(default::MERCURE_SUBSCRIBER_JWT_KEY)%')]
|
||||||
|
private readonly string $mercureSubscriberKey,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->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 ? '<fg=green>PASS</>' : '<fg=red>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}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
88
framework/php/tests/Command/BridgeDoctorCommandTest.php
Normal file
88
framework/php/tests/Command/BridgeDoctorCommandTest.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PhpQml\Bridge\Tests\Command;
|
||||||
|
|
||||||
|
use PhpQml\Bridge\Command\BridgeDoctorCommand;
|
||||||
|
use PhpQml\Bridge\Publisher;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
use Symfony\Component\Mercure\HubInterface;
|
||||||
|
use Symfony\Component\Mercure\Update;
|
||||||
|
|
||||||
|
#[CoversClass(BridgeDoctorCommand::class)]
|
||||||
|
final class BridgeDoctorCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testAllPassesWhenEnvIsFullyConfigured(): 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);
|
||||||
|
|
||||||
|
$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 ''; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user