diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 37c2245..da4c573 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -67,3 +67,22 @@ jobs: - name: qmllint working-directory: framework/skeleton run: cmake --build build/qml --target all_qmllint + + - name: Install FrankenPHP + run: | + curl -fsSL -o /usr/local/bin/frankenphp \ + https://github.com/php/frankenphp/releases/download/v1.12.2/frankenphp-linux-x86_64 + chmod +x /usr/local/bin/frankenphp + + - name: Maker-output snapshot test + run: framework/php/tests/snapshot/run.sh + + - name: Build the todo example + working-directory: examples/todo + run: | + make install + make build + + - name: Bridge-integration test (HTTP/SSE round-trip + crash-recover) + working-directory: examples/todo + run: ./tests/integration.sh diff --git a/examples/todo/symfony/src/Controller/TodoController.php b/examples/todo/symfony/src/Controller/TodoController.php index dbdf9f8..50d2a53 100644 --- a/examples/todo/symfony/src/Controller/TodoController.php +++ b/examples/todo/symfony/src/Controller/TodoController.php @@ -10,7 +10,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** * Auto-generated CRUD controller for the Todo bridge resource. @@ -21,7 +21,7 @@ final class TodoController { public function __construct( private readonly EntityManagerInterface $em, - private readonly SerializerInterface $serializer, + private readonly NormalizerInterface $normalizer, ) { } @@ -30,7 +30,7 @@ final class TodoController { $items = $this->em->getRepository(Todo::class)->findAll(); - return new JsonResponse($this->serializer->normalize($items, 'json')); + return new JsonResponse($this->normalizer->normalize($items, 'json')); } #[Route('', name: 'todo_create', methods: ['POST'])] @@ -51,7 +51,7 @@ final class TodoController $this->em->flush(); return new JsonResponse( - $this->serializer->normalize($entity, 'json'), + $this->normalizer->normalize($entity, 'json'), Response::HTTP_CREATED, ); } @@ -81,7 +81,7 @@ final class TodoController $this->em->flush(); - return new JsonResponse($this->serializer->normalize($entity, 'json')); + return new JsonResponse($this->normalizer->normalize($entity, 'json')); } #[Route('/{id}', name: 'todo_delete', methods: ['DELETE'])] diff --git a/framework/php/.php-cs-fixer.dist.php b/framework/php/.php-cs-fixer.dist.php index a184838..3080cce 100644 --- a/framework/php/.php-cs-fixer.dist.php +++ b/framework/php/.php-cs-fixer.dist.php @@ -4,7 +4,12 @@ $finder = (new PhpCsFixer\Finder()) ->in([__DIR__ . '/src', __DIR__ . '/tests']) // Maker templates use short-echo syntax and alternative-syntax control // structures by design — cs-fixer's @Symfony rules would mangle them. - ->notPath('Maker/templates'); + ->notPath('Maker/templates') + // Maker-output snapshot baselines are the makers' exact output and + // must not be auto-rewritten; if they need updating, the maker + // template changes and the snapshot is regenerated explicitly. + // (notPath is relative to each `in()` dir, not the project root.) + ->notPath('snapshot'); return (new PhpCsFixer\Config()) ->setRiskyAllowed(true) diff --git a/framework/php/phpstan.neon.dist b/framework/php/phpstan.neon.dist index 02e93da..ec6c62e 100644 --- a/framework/php/phpstan.neon.dist +++ b/framework/php/phpstan.neon.dist @@ -12,3 +12,5 @@ parameters: # Maker templates use variables injected by the Generator at # render time; static analysis can't see the binding. - src/Maker/templates/* + # Snapshot baselines are reference output, not maintained code. + - tests/snapshot/* diff --git a/framework/php/src/Maker/BridgeResourceMaker.php b/framework/php/src/Maker/BridgeResourceMaker.php index 1cacd27..f41a750 100644 --- a/framework/php/src/Maker/BridgeResourceMaker.php +++ b/framework/php/src/Maker/BridgeResourceMaker.php @@ -20,7 +20,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Uid\Uuid; /** @@ -163,7 +163,7 @@ final class BridgeResourceMaker extends AbstractMaker $dependencies->addClassDependency(JsonResponse::class, 'symfony/http-foundation'); $dependencies->addClassDependency(Request::class, 'symfony/http-foundation'); $dependencies->addClassDependency(EntityManagerInterface::class, 'doctrine/orm'); - $dependencies->addClassDependency(SerializerInterface::class, 'symfony/serializer'); + $dependencies->addClassDependency(NormalizerInterface::class, 'symfony/serializer'); $dependencies->addClassDependency(Uuid::class, 'symfony/uid'); } } diff --git a/framework/php/src/Maker/templates/Command.tpl.php b/framework/php/src/Maker/templates/Command.tpl.php index c267242..cfa4d42 100644 --- a/framework/php/src/Maker/templates/Command.tpl.php +++ b/framework/php/src/Maker/templates/Command.tpl.php @@ -27,7 +27,9 @@ final class { // TODO: implement . // Read inputs from $request->getPayload() / json_decode($request->getContent(), true). - // Mutate entities, then $this->em->flush(). + // Mutate entities, then $this->em->flush() (already wired up below). + + $this->em->flush(); return new JsonResponse(['ok' => true]); } diff --git a/framework/php/src/Maker/templates/Controller.tpl.php b/framework/php/src/Maker/templates/Controller.tpl.php index a6f7853..9d011ea 100644 --- a/framework/php/src/Maker/templates/Controller.tpl.php +++ b/framework/php/src/Maker/templates/Controller.tpl.php @@ -10,7 +10,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** * Auto-generated CRUD controller for the bridge resource. @@ -21,7 +21,7 @@ final class Controller { public function __construct( private readonly EntityManagerInterface $em, - private readonly SerializerInterface $serializer, + private readonly NormalizerInterface $normalizer, ) { } @@ -30,7 +30,7 @@ final class Controller { $items = $this->em->getRepository(::class)->findAll(); - return new JsonResponse($this->serializer->normalize($items, 'json')); + return new JsonResponse($this->normalizer->normalize($items, 'json')); } #[Route('', name: '_create', methods: ['POST'])] @@ -49,7 +49,7 @@ final class Controller $this->em->flush(); return new JsonResponse( - $this->serializer->normalize($entity, 'json'), + $this->normalizer->normalize($entity, 'json'), Response::HTTP_CREATED, ); } @@ -76,7 +76,7 @@ final class Controller $this->em->flush(); - return new JsonResponse($this->serializer->normalize($entity, 'json')); + return new JsonResponse($this->normalizer->normalize($entity, 'json')); } #[Route('/{id}', name: '_delete', methods: ['DELETE'])] diff --git a/framework/php/tests/snapshot/MarkAllDoneController.php b/framework/php/tests/snapshot/MarkAllDoneController.php new file mode 100644 index 0000000..85d683c --- /dev/null +++ b/framework/php/tests/snapshot/MarkAllDoneController.php @@ -0,0 +1,35 @@ +em->flush(). Doctrine entities tagged with #[BridgeResource] + * publish their changes to Mercure automatically. + */ +final class MarkAllDoneController +{ + public function __construct( + private readonly EntityManagerInterface $em, + ) { + } + + #[Route('/api/mark-all-done', name: 'mark-all-done', methods: ['POST'])] + public function __invoke(Request $request): JsonResponse + { + // TODO: implement MarkAllDone. + // Read inputs from $request->getPayload() / json_decode($request->getContent(), true). + // Mutate entities, then $this->em->flush() (already wired up below). + + $this->em->flush(); + + return new JsonResponse(['ok' => true]); + } +} diff --git a/framework/php/tests/snapshot/Todo.php b/framework/php/tests/snapshot/Todo.php new file mode 100644 index 0000000..b441a9a --- /dev/null +++ b/framework/php/tests/snapshot/Todo.php @@ -0,0 +1,54 @@ +id = Uuid::v7(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function isDone(): bool + { + return $this->done; + } + + public function setDone(bool $done): void + { + $this->done = $done; + } +} diff --git a/framework/php/tests/snapshot/TodoController.php b/framework/php/tests/snapshot/TodoController.php new file mode 100644 index 0000000..50d2a53 --- /dev/null +++ b/framework/php/tests/snapshot/TodoController.php @@ -0,0 +1,101 @@ +em->getRepository(Todo::class)->findAll(); + + return new JsonResponse($this->normalizer->normalize($items, 'json')); + } + + #[Route('', name: 'todo_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { + $data = json_decode((string) $request->getContent(), true) ?? []; + $entity = new Todo(); + + if (isset($data['title'])) { + $entity->setTitle((string) $data['title']); + } + + if (isset($data['done'])) { + $entity->setDone((bool) $data['done']); + } + + $this->em->persist($entity); + $this->em->flush(); + + return new JsonResponse( + $this->normalizer->normalize($entity, 'json'), + Response::HTTP_CREATED, + ); + } + + #[Route('/{id}', name: 'todo_update', methods: ['PATCH'])] + public function update(string $id, Request $request): JsonResponse + { + $entity = $this->em->getRepository(Todo::class)->find($id); + + if (null === $entity) { + return new JsonResponse( + ['title' => 'Not Found', 'status' => 404], + Response::HTTP_NOT_FOUND, + ['Content-Type' => 'application/problem+json'], + ); + } + + $data = json_decode((string) $request->getContent(), true) ?? []; + + if (isset($data['title'])) { + $entity->setTitle((string) $data['title']); + } + + if (isset($data['done'])) { + $entity->setDone((bool) $data['done']); + } + + $this->em->flush(); + + return new JsonResponse($this->normalizer->normalize($entity, 'json')); + } + + #[Route('/{id}', name: 'todo_delete', methods: ['DELETE'])] + public function delete(string $id): JsonResponse + { + $entity = $this->em->getRepository(Todo::class)->find($id); + + if (null === $entity) { + return new JsonResponse(null, Response::HTTP_NO_CONTENT); + } + + $this->em->remove($entity); + $this->em->flush(); + + return new JsonResponse(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/framework/php/tests/snapshot/TodoList.qml b/framework/php/tests/snapshot/TodoList.qml new file mode 100644 index 0000000..5e7c339 --- /dev/null +++ b/framework/php/tests/snapshot/TodoList.qml @@ -0,0 +1,28 @@ +// Auto-generated by `bin/console make:bridge:resource Todo`. +// Drop this into your QML and customize the delegate to taste. + +import QtQuick +import QtQuick.Controls +import PhpQml.Bridge + +ListView { + id: todoList + + model: ReactiveListModel { + baseUrl: BackendConnection.url + token: BackendConnection.token + source: "/api/todos" + topic: "app://model/todo" + } + + delegate: ItemDelegate { + required property string id + required property string title + required property bool done + required property bool pending + + text: title + (done ? " ✓" : "") + opacity: pending ? 0.5 : 1.0 + width: ListView.view.width + } +} diff --git a/framework/php/tests/snapshot/TodoWindow.qml b/framework/php/tests/snapshot/TodoWindow.qml new file mode 100644 index 0000000..b37d2b6 --- /dev/null +++ b/framework/php/tests/snapshot/TodoWindow.qml @@ -0,0 +1,36 @@ +// Auto-generated by `bin/console make:bridge:window Todo`. +// Edit freely; re-running the maker won't overwrite this file. + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import PhpQml.Bridge + +ApplicationWindow { + id: todoWindow + width: 720 + height: 520 + visible: true + title: "Todo" + + AppShell { + anchors.fill: parent + + // Replace this with the window's content. The AppShell parent + // takes care of Reconnecting / Offline UI overlays. + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + + Label { + text: "Todo" + font.pixelSize: 18 + font.bold: true + } + + // TODO: window content here. + Item { Layout.fillWidth: true; Layout.fillHeight: true } + } + } +} diff --git a/framework/php/tests/snapshot/run.sh b/framework/php/tests/snapshot/run.sh new file mode 100755 index 0000000..1564eef --- /dev/null +++ b/framework/php/tests/snapshot/run.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Maker-output snapshot test. +# Runs the three Phase-2/Phase-3 makers in a clean temp app and diffs +# the output against the baselines in this directory. Detects silent +# generator drift: any change to a maker template requires the +# corresponding baseline to be updated in the same commit. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +BUNDLE="$PROJECT_ROOT/framework/php" +WORK="$(mktemp -d)" + +trap 'rm -rf "$WORK"' EXIT INT TERM + +# Mirror the skeleton's tracked files into the temp dir. +git -C "$PROJECT_ROOT" archive HEAD framework/skeleton/ | tar -xf - -C "$WORK" +APP="$WORK/framework/skeleton" + +# Point the test app's path repo at the actual bundle dir (the +# default '../../php' would resolve relative to /tmp). +sed -i "s|\"../../php\"|\"$BUNDLE\"|" "$APP/symfony/composer.json" +rm -f "$APP/symfony/composer.lock" +( cd "$APP/symfony" && composer install --no-interaction --quiet ) + +# Remove the existing maker outputs so the regenerators don't bail. +rm -f "$APP/symfony/src/Entity/Todo.php" +rm -f "$APP/symfony/src/Controller/TodoController.php" +rm -f "$APP/qml/TodoList.qml" + +# Run every maker we cover. +( cd "$APP/symfony" \ + && bin/console make:bridge:resource Todo --no-interaction >/dev/null \ + && bin/console make:bridge:command MarkAllDone --no-interaction >/dev/null \ + && bin/console make:bridge:window Todo --no-interaction >/dev/null ) + +# Compare each generated file to its snapshot baseline. +fail=0 +check() { + local generated="$1" + local baseline="$2" + if ! diff -q "$generated" "$baseline" >/dev/null 2>&1; then + echo "✗ snapshot mismatch: $(basename "$baseline")" >&2 + diff -u "$baseline" "$generated" >&2 || true + fail=1 + else + echo "✓ $(basename "$baseline")" + fi +} + +check "$APP/symfony/src/Entity/Todo.php" "$SCRIPT_DIR/Todo.php" +check "$APP/symfony/src/Controller/TodoController.php" "$SCRIPT_DIR/TodoController.php" +check "$APP/qml/TodoList.qml" "$SCRIPT_DIR/TodoList.qml" +check "$APP/symfony/src/Controller/MarkAllDoneController.php" "$SCRIPT_DIR/MarkAllDoneController.php" +check "$APP/qml/TodoWindow.qml" "$SCRIPT_DIR/TodoWindow.qml" + +if [ "$fail" -ne 0 ]; then + echo "Snapshot test failed. If the change is intended, update the baselines under $SCRIPT_DIR/." >&2 + exit 1 +fi + +echo "All maker outputs match snapshots." diff --git a/framework/skeleton/Makefile b/framework/skeleton/Makefile index b096c87..19b2014 100644 --- a/framework/skeleton/Makefile +++ b/framework/skeleton/Makefile @@ -37,6 +37,7 @@ clean: ## Remove build artefacts rm -rf $(BUILD_DIR) .PHONY: quality -quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint +quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, maker snapshots cd ../php && composer quality cmake --build $(BUILD_DIR) --target all_qmllint + ../php/tests/snapshot/run.sh diff --git a/framework/skeleton/symfony/src/Controller/TodoController.php b/framework/skeleton/symfony/src/Controller/TodoController.php index dbdf9f8..50d2a53 100644 --- a/framework/skeleton/symfony/src/Controller/TodoController.php +++ b/framework/skeleton/symfony/src/Controller/TodoController.php @@ -10,7 +10,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** * Auto-generated CRUD controller for the Todo bridge resource. @@ -21,7 +21,7 @@ final class TodoController { public function __construct( private readonly EntityManagerInterface $em, - private readonly SerializerInterface $serializer, + private readonly NormalizerInterface $normalizer, ) { } @@ -30,7 +30,7 @@ final class TodoController { $items = $this->em->getRepository(Todo::class)->findAll(); - return new JsonResponse($this->serializer->normalize($items, 'json')); + return new JsonResponse($this->normalizer->normalize($items, 'json')); } #[Route('', name: 'todo_create', methods: ['POST'])] @@ -51,7 +51,7 @@ final class TodoController $this->em->flush(); return new JsonResponse( - $this->serializer->normalize($entity, 'json'), + $this->normalizer->normalize($entity, 'json'), Response::HTTP_CREATED, ); } @@ -81,7 +81,7 @@ final class TodoController $this->em->flush(); - return new JsonResponse($this->serializer->normalize($entity, 'json')); + return new JsonResponse($this->normalizer->normalize($entity, 'json')); } #[Route('/{id}', name: 'todo_delete', methods: ['DELETE'])]