diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 423902c..8232b61 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -10,11 +10,60 @@ jobs: quality: name: Quality runs-on: ubuntu-latest + steps: - name: Checkout uses: actions/checkout@v4 - - name: Quality (placeholder) + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: curl, json, mbstring + tools: composer:v2 + coverage: none + + - name: Cache Composer + uses: actions/cache@v4 + with: + path: ~/.cache/composer + key: composer-${{ hashFiles('framework/php/composer.json') }} + + - name: Install bundle dependencies + working-directory: framework/php + run: composer install --no-interaction --prefer-dist + + - name: PHPStan + working-directory: framework/php + run: composer phpstan + + - name: php-cs-fixer (check) + working-directory: framework/php + run: composer cs:check + + - name: PHPUnit + working-directory: framework/php + run: composer phpunit + + - name: Setup Qt 6 + uses: jurplel/install-qt-action@v4 + with: + version: '6.5.*' + modules: 'qtquickcontrols2' + cache: true + + - name: Cache CMake build + uses: actions/cache@v4 + with: + path: framework/skeleton/build + key: cmake-${{ runner.os }}-${{ hashFiles('framework/qml/**', 'framework/skeleton/qml/**') }} + + - name: Build QML module + skeleton + working-directory: framework/skeleton run: | - echo "quality job — populated in Phase 1 sub-commit 7" - echo "will run: PHPStan, php-cs-fixer (check), PHPUnit, qmllint" + cmake -S qml -B build/qml + cmake --build build/qml --parallel + + - name: qmllint + working-directory: framework/skeleton + run: cmake --build build/qml --target all_qmllint diff --git a/framework/php/.php-cs-fixer.dist.php b/framework/php/.php-cs-fixer.dist.php new file mode 100644 index 0000000..a8643f5 --- /dev/null +++ b/framework/php/.php-cs-fixer.dist.php @@ -0,0 +1,17 @@ +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); diff --git a/framework/php/composer.json b/framework/php/composer.json index 20fb4b7..5c88922 100644 --- a/framework/php/composer.json +++ b/framework/php/composer.json @@ -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" } diff --git a/framework/php/phpstan.neon.dist b/framework/php/phpstan.neon.dist new file mode 100644 index 0000000..430bfef --- /dev/null +++ b/framework/php/phpstan.neon.dist @@ -0,0 +1,10 @@ +includes: + - vendor/phpstan/phpstan-symfony/extension.neon + +parameters: + level: 6 + paths: + - src + - tests + excludePaths: + - vendor/* diff --git a/framework/php/src/BridgeBundle.php b/framework/php/src/BridgeBundle.php index dd28b3d..44a887a 100644 --- a/framework/php/src/BridgeBundle.php +++ b/framework/php/src/BridgeBundle.php @@ -11,9 +11,12 @@ use Symfony\Component\HttpKernel\Bundle\AbstractBundle; final class BridgeBundle extends AbstractBundle { + /** + * @param array $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 diff --git a/framework/php/src/Command/BridgeDoctorCommand.php b/framework/php/src/Command/BridgeDoctorCommand.php index db133c5..e4291a7 100644 --- a/framework/php/src/Command/BridgeDoctorCommand.php +++ b/framework/php/src/Command/BridgeDoctorCommand.php @@ -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}", ]; } } diff --git a/framework/php/src/Publisher.php b/framework/php/src/Publisher.php index 1fedcb6..f938198 100644 --- a/framework/php/src/Publisher.php +++ b/framework/php/src/Publisher.php @@ -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, )); } diff --git a/framework/php/src/SessionAuthenticator.php b/framework/php/src/SessionAuthenticator.php index f635145..4f1b5d4 100644 --- a/framework/php/src/SessionAuthenticator.php +++ b/framework/php/src/SessionAuthenticator.php @@ -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( [ diff --git a/framework/php/tests/Command/BridgeDoctorCommandTest.php b/framework/php/tests/Command/BridgeDoctorCommandTest.php index 3d2a863..57da61a 100644 --- a/framework/php/tests/Command/BridgeDoctorCommandTest.php +++ b/framework/php/tests/Command/BridgeDoctorCommandTest.php @@ -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 ''; } - }); - } } diff --git a/framework/php/tests/Helper/HubSpy.php b/framework/php/tests/Helper/HubSpy.php new file mode 100644 index 0000000..555d1e3 --- /dev/null +++ b/framework/php/tests/Helper/HubSpy.php @@ -0,0 +1,50 @@ +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; + } +} diff --git a/framework/php/tests/PublisherTest.php b/framework/php/tests/PublisherTest.php index ac8c6a6..1042640 100644 --- a/framework/php/tests/PublisherTest.php +++ b/framework/php/tests/PublisherTest.php @@ -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()); } } diff --git a/framework/php/tests/SessionAuthenticatorTest.php b/framework/php/tests/SessionAuthenticatorTest.php index eac0b86..76e703f 100644 --- a/framework/php/tests/SessionAuthenticatorTest.php +++ b/framework/php/tests/SessionAuthenticatorTest.php @@ -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); diff --git a/framework/skeleton/Makefile b/framework/skeleton/Makefile index 151fe24..b096c87 100644 --- a/framework/skeleton/Makefile +++ b/framework/skeleton/Makefile @@ -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 diff --git a/framework/skeleton/qml/Main.qml b/framework/skeleton/qml/Main.qml index c79898b..0676887 100644 --- a/framework/skeleton/qml/Main.qml +++ b/framework/skeleton/qml/Main.qml @@ -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") }