PHPStan (level 6 + symfony extension) and PHP CS Fixer (Symfony + PHP83Migration ruleset) configs at framework/php/. composer.json exposes phpstan / cs:check / cs:fix / phpunit / quality scripts. PHPStan-clean across the bundle; cs:check is happy after auto-fix applied @Symfony idioms (yoda, leading-backslash JSON_*, blank-line before return). Test mocks consolidated into a HubSpy helper to keep PHPStan happy about by-ref captures. Skeleton's Makefile target `quality` chains `composer quality` (in framework/php/) with cmake's all_qmllint target. Local run is green — 11 tests / 32 assertions, no PHPStan errors, cs-fixer clean, qmllint emits advisory warnings only. Layout fix in skeleton's Main.qml: status-dot Rectangles inside RowLayout now use Layout.preferredWidth/Height instead of width/height to satisfy Quick.layout-positioning checks. .gitea/workflows/ci.yml replaces the placeholder with a real `quality` job: setup-php, composer install (cached), the four PHP checks, Qt 6 via install-qt-action (cached), QML module build, qmllint via the all_qmllint CMake target. Workflow exists from this commit onward even if a runner isn't provisioned yet. bridge:doctor lost the Publisher dependency since it was only used as a "service is wired" marker — the command being injectable already proves that. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
17
framework/php/.php-cs-fixer.dist.php
Normal file
17
framework/php/.php-cs-fixer.dist.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
$finder = (new PhpCsFixer\Finder())
|
||||
->in([__DIR__ . '/src', __DIR__ . '/tests']);
|
||||
|
||||
return (new PhpCsFixer\Config())
|
||||
->setRiskyAllowed(true)
|
||||
->setRules([
|
||||
'@Symfony' => true,
|
||||
'@Symfony:risky' => true,
|
||||
'@PHP83Migration' => true,
|
||||
'declare_strict_types' => true,
|
||||
'native_function_invocation' => false,
|
||||
'phpdoc_align' => false,
|
||||
'binary_operator_spaces' => ['default' => 'align_single_space_minimal'],
|
||||
])
|
||||
->setFinder($finder);
|
||||
@@ -31,5 +31,16 @@
|
||||
"PhpQml\\Bridge\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"phpstan": "phpstan analyse --memory-limit=512M",
|
||||
"cs:check": "php-cs-fixer check --diff --allow-unsupported-php-version=yes",
|
||||
"cs:fix": "php-cs-fixer fix --allow-unsupported-php-version=yes",
|
||||
"phpunit": "phpunit",
|
||||
"quality": [
|
||||
"@phpstan",
|
||||
"@cs:check",
|
||||
"@phpunit"
|
||||
]
|
||||
},
|
||||
"minimum-stability": "stable"
|
||||
}
|
||||
|
||||
10
framework/php/phpstan.neon.dist
Normal file
10
framework/php/phpstan.neon.dist
Normal file
@@ -0,0 +1,10 @@
|
||||
includes:
|
||||
- vendor/phpstan/phpstan-symfony/extension.neon
|
||||
|
||||
parameters:
|
||||
level: 6
|
||||
paths:
|
||||
- src
|
||||
- tests
|
||||
excludePaths:
|
||||
- vendor/*
|
||||
@@ -11,9 +11,12 @@ use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
|
||||
|
||||
final class BridgeBundle extends AbstractBundle
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
|
||||
{
|
||||
$container->import(__DIR__ . '/../config/services.yaml');
|
||||
$container->import(__DIR__.'/../config/services.yaml');
|
||||
}
|
||||
|
||||
public function configure(DefinitionConfigurator $definition): void
|
||||
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
@@ -23,7 +22,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
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)%')]
|
||||
@@ -47,34 +45,30 @@ final class BridgeDoctorCommand extends Command
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->title('php-qml bridge — readiness checks');
|
||||
|
||||
// PHP version + extensions are enforced by composer, so we don't
|
||||
// re-check them runtime — would always be true on a working install.
|
||||
$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 !== '',
|
||||
'' !== $this->bridgeToken,
|
||||
'Set BRIDGE_TOKEN in .env.local; the Qt host expects this as the bearer token.'],
|
||||
['MERCURE_URL env set',
|
||||
$this->mercureUrl !== '',
|
||||
'' !== $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 !== '',
|
||||
'' !== $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 !== '',
|
||||
'' !== $this->mercureSubscriberKey,
|
||||
'Set MERCURE_SUBSCRIBER_JWT_KEY in .env.local; or rely on the Caddy `anonymous` directive in dev mode.'],
|
||||
];
|
||||
|
||||
$rows = [];
|
||||
$rows = [];
|
||||
$allPass = true;
|
||||
foreach ($checks as [$label, $ok, $hint]) {
|
||||
$rows[] = [
|
||||
@@ -100,10 +94,12 @@ final class BridgeDoctorCommand extends Command
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -120,17 +116,19 @@ final class BridgeDoctorCommand extends Command
|
||||
],
|
||||
]);
|
||||
$body = @file_get_contents($url, false, $ctx);
|
||||
if ($body === false) {
|
||||
if (false === $body) {
|
||||
$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,
|
||||
'ok' => 200 === $status,
|
||||
'status' => $status,
|
||||
'detail' => $status === 200 ? '' : "expected 200, got {$status}",
|
||||
'detail' => 200 === $status ? '' : "expected 200, got {$status}",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ final readonly class Publisher
|
||||
{
|
||||
return $this->hub->publish(new Update(
|
||||
$topic,
|
||||
json_encode($envelope, JSON_THROW_ON_ERROR),
|
||||
json_encode($envelope, \JSON_THROW_ON_ERROR),
|
||||
$private,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ final class SessionAuthenticator extends AbstractAuthenticator
|
||||
) {
|
||||
}
|
||||
|
||||
public function supports(Request $request): ?bool
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return $request->headers->has('Authorization');
|
||||
}
|
||||
@@ -43,7 +43,7 @@ final class SessionAuthenticator extends AbstractAuthenticator
|
||||
}
|
||||
|
||||
$token = substr($header, 7);
|
||||
if ($this->expectedToken === '' || !hash_equals($this->expectedToken, $token)) {
|
||||
if ('' === $this->expectedToken || !hash_equals($this->expectedToken, $token)) {
|
||||
throw new AuthenticationException('Bearer token invalid.');
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ final class SessionAuthenticator extends AbstractAuthenticator
|
||||
return null;
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
||||
{
|
||||
return new JsonResponse(
|
||||
[
|
||||
|
||||
@@ -5,12 +5,9 @@ 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
|
||||
@@ -18,11 +15,10 @@ 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',
|
||||
bridgeToken: 'devtoken',
|
||||
mercureUrl: 'http://127.0.0.1:8765/.well-known/mercure',
|
||||
mercurePublisherKey: 'devkey',
|
||||
mercureSubscriberKey: 'devkey',
|
||||
);
|
||||
$tester = new CommandTester($command);
|
||||
|
||||
@@ -37,11 +33,10 @@ final class BridgeDoctorCommandTest extends TestCase
|
||||
public function testFailsAndShowsHintsWhenEnvIsMissing(): void
|
||||
{
|
||||
$command = new BridgeDoctorCommand(
|
||||
publisher: $this->makePublisher(),
|
||||
bridgeToken: '',
|
||||
mercureUrl: '',
|
||||
mercurePublisherKey: '',
|
||||
mercureSubscriberKey: '',
|
||||
bridgeToken: '',
|
||||
mercureUrl: '',
|
||||
mercurePublisherKey: '',
|
||||
mercureSubscriberKey: '',
|
||||
);
|
||||
$tester = new CommandTester($command);
|
||||
|
||||
@@ -58,11 +53,10 @@ final class BridgeDoctorCommandTest extends TestCase
|
||||
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',
|
||||
bridgeToken: 'devtoken',
|
||||
mercureUrl: 'http://127.0.0.1:8765/.well-known/mercure',
|
||||
mercurePublisherKey: 'devkey',
|
||||
mercureSubscriberKey: 'devkey',
|
||||
);
|
||||
$tester = new CommandTester($command);
|
||||
|
||||
@@ -72,17 +66,4 @@ final class BridgeDoctorCommandTest extends TestCase
|
||||
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 ''; }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
50
framework/php/tests/Helper/HubSpy.php
Normal file
50
framework/php/tests/Helper/HubSpy.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PhpQml\Bridge\Tests\Helper;
|
||||
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
use Symfony\Component\Mercure\Jwt\TokenFactoryInterface;
|
||||
use Symfony\Component\Mercure\Jwt\TokenProviderInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
|
||||
/**
|
||||
* Minimal HubInterface fake that records the last published Update.
|
||||
* Used by Publisher / BridgeDoctorCommand tests.
|
||||
*/
|
||||
final class HubSpy implements HubInterface
|
||||
{
|
||||
public ?Update $captured = null;
|
||||
|
||||
public function __construct(private readonly string $stubReturnId = '')
|
||||
{
|
||||
}
|
||||
|
||||
public function getUrl(): string
|
||||
{
|
||||
return 'http://localhost/.well-known/mercure';
|
||||
}
|
||||
|
||||
public function getPublicUrl(): string
|
||||
{
|
||||
return $this->getUrl();
|
||||
}
|
||||
|
||||
public function getProvider(): TokenProviderInterface
|
||||
{
|
||||
throw new \LogicException('HubSpy::getProvider not used in tests.');
|
||||
}
|
||||
|
||||
public function getFactory(): ?TokenFactoryInterface
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function publish(Update $update): string
|
||||
{
|
||||
$this->captured = $update;
|
||||
|
||||
return $this->stubReturnId;
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ declare(strict_types=1);
|
||||
namespace PhpQml\Bridge\Tests;
|
||||
|
||||
use PhpQml\Bridge\Publisher;
|
||||
use PhpQml\Bridge\Tests\Helper\HubSpy;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
|
||||
#[CoversClass(Publisher::class)]
|
||||
@@ -15,58 +15,30 @@ final class PublisherTest extends TestCase
|
||||
{
|
||||
public function testPublishWritesEnvelopeAsJsonOnTheGivenTopic(): void
|
||||
{
|
||||
$captured = null;
|
||||
$hub = new class($captured) implements HubInterface {
|
||||
public function __construct(private mixed &$captured) {}
|
||||
|
||||
public function getUrl(): string { return 'http://localhost/.well-known/mercure'; }
|
||||
|
||||
public function getPublicUrl(): string { return $this->getUrl(); }
|
||||
|
||||
public function getProvider(): \Symfony\Component\Mercure\Jwt\TokenProviderInterface
|
||||
{
|
||||
throw new \LogicException('not used in test');
|
||||
}
|
||||
|
||||
public function getFactory(): ?\Symfony\Component\Mercure\Jwt\TokenFactoryInterface
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function publish(Update $update): string
|
||||
{
|
||||
$this->captured = $update;
|
||||
return 'urn:uuid:test';
|
||||
}
|
||||
};
|
||||
|
||||
$hub = new HubSpy('urn:uuid:test');
|
||||
$publisher = new Publisher($hub);
|
||||
$id = $publisher->publish('app://model/todo', ['op' => 'upsert', 'id' => '1', 'data' => ['done' => true], 'version' => 7]);
|
||||
|
||||
$id = $publisher->publish(
|
||||
'app://model/todo',
|
||||
['op' => 'upsert', 'id' => '1', 'data' => ['done' => true], 'version' => 7],
|
||||
);
|
||||
|
||||
self::assertSame('urn:uuid:test', $id);
|
||||
self::assertInstanceOf(Update::class, $captured);
|
||||
self::assertSame(['app://model/todo'], $captured->getTopics());
|
||||
self::assertInstanceOf(Update::class, $hub->captured);
|
||||
self::assertSame(['app://model/todo'], $hub->captured->getTopics());
|
||||
self::assertJsonStringEqualsJsonString(
|
||||
'{"op":"upsert","id":"1","data":{"done":true},"version":7}',
|
||||
$captured->getData(),
|
||||
$hub->captured->getData(),
|
||||
);
|
||||
self::assertFalse($captured->isPrivate());
|
||||
self::assertFalse($hub->captured->isPrivate());
|
||||
}
|
||||
|
||||
public function testPrivateFlagIsForwarded(): void
|
||||
{
|
||||
$captured = null;
|
||||
$hub = new class($captured) implements HubInterface {
|
||||
public function __construct(private mixed &$captured) {}
|
||||
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 { $this->captured = $update; return ''; }
|
||||
};
|
||||
|
||||
$hub = new HubSpy();
|
||||
(new Publisher($hub))->publish('app://event/internal', ['data' => 'x'], private: true);
|
||||
|
||||
self::assertTrue($captured->isPrivate());
|
||||
self::assertNotNull($hub->captured);
|
||||
self::assertTrue($hub->captured->isPrivate());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ final class SessionAuthenticatorTest extends TestCase
|
||||
|
||||
public function testAuthenticateAcceptsMatchingBearerToken(): void
|
||||
{
|
||||
$auth = new SessionAuthenticator('s3cret');
|
||||
$auth = new SessionAuthenticator('s3cret');
|
||||
$request = new Request();
|
||||
$request->headers->set('Authorization', 'Bearer s3cret');
|
||||
|
||||
@@ -40,7 +40,7 @@ final class SessionAuthenticatorTest extends TestCase
|
||||
|
||||
public function testAuthenticateRejectsMissingBearerScheme(): void
|
||||
{
|
||||
$auth = new SessionAuthenticator('s3cret');
|
||||
$auth = new SessionAuthenticator('s3cret');
|
||||
$request = new Request();
|
||||
$request->headers->set('Authorization', 'Basic deadbeef');
|
||||
|
||||
@@ -51,7 +51,7 @@ final class SessionAuthenticatorTest extends TestCase
|
||||
|
||||
public function testAuthenticateRejectsWrongToken(): void
|
||||
{
|
||||
$auth = new SessionAuthenticator('s3cret');
|
||||
$auth = new SessionAuthenticator('s3cret');
|
||||
$request = new Request();
|
||||
$request->headers->set('Authorization', 'Bearer wrong');
|
||||
|
||||
@@ -63,7 +63,7 @@ final class SessionAuthenticatorTest extends TestCase
|
||||
public function testAuthenticateRejectsEmptyExpectedToken(): void
|
||||
{
|
||||
// Avoids passing a misconfigured (empty) deployment.
|
||||
$auth = new SessionAuthenticator('');
|
||||
$auth = new SessionAuthenticator('');
|
||||
$request = new Request();
|
||||
$request->headers->set('Authorization', 'Bearer ');
|
||||
|
||||
@@ -73,10 +73,9 @@ final class SessionAuthenticatorTest extends TestCase
|
||||
|
||||
public function testAuthenticationFailureProducesProblemJson(): void
|
||||
{
|
||||
$auth = new SessionAuthenticator('s3cret');
|
||||
$auth = new SessionAuthenticator('s3cret');
|
||||
$response = $auth->onAuthenticationFailure(new Request(), new AuthenticationException('Bearer token invalid.'));
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||
self::assertSame('application/problem+json', $response->headers->get('Content-Type'));
|
||||
$body = json_decode((string) $response->getContent(), true);
|
||||
|
||||
@@ -37,6 +37,6 @@ clean: ## Remove build artefacts
|
||||
rm -rf $(BUILD_DIR)
|
||||
|
||||
.PHONY: quality
|
||||
quality: ## (TBD) Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint
|
||||
@echo "quality: not yet implemented (Phase 1 sub-commit 7)"
|
||||
@exit 1
|
||||
quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint
|
||||
cd ../php && composer quality
|
||||
cmake --build $(BUILD_DIR) --target all_qmllint
|
||||
|
||||
@@ -62,7 +62,7 @@ ApplicationWindow {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Rectangle {
|
||||
width: 12; height: 12; radius: 6
|
||||
Layout.preferredWidth: 12; Layout.preferredHeight: 12; radius: 6
|
||||
color: BackendConnection.connectionState === BackendConnection.Online
|
||||
? "#3ab36c"
|
||||
: (BackendConnection.connectionState === BackendConnection.Error ? "#d8503c" : "#d89614")
|
||||
@@ -72,7 +72,7 @@ ApplicationWindow {
|
||||
Item { width: 12 }
|
||||
|
||||
Rectangle {
|
||||
width: 12; height: 12; radius: 6
|
||||
Layout.preferredWidth: 12; Layout.preferredHeight: 12; radius: 6
|
||||
color: mercure.active ? "#3ab36c" : "#666"
|
||||
}
|
||||
Label { text: "Mercure: " + (mercure.active ? "subscribed" : "off") }
|
||||
|
||||
Reference in New Issue
Block a user