Phase 3 sub-commit 5: maker-output snapshot test + phase closure

framework/php/tests/snapshot/ holds reference output for every shipped
maker (resource Todo, command MarkAllDone, window Todo). The
run.sh script:

  - git-archives the skeleton into a temp dir
  - composer-installs against the bundle's real path
  - removes the existing maker outputs so the regenerators don't bail
  - runs the three makers
  - diffs each generated file against the matching baseline

CI / make quality fail on any drift; if a template change is intended,
the baselines must be regenerated in the same commit. Wired into:

  - framework/skeleton/Makefile's `quality` target (local/dev runs)
  - .gitea/workflows/ci.yml (CI runs after qmllint)

Plus a few hardenings discovered while wiring this up:

  - The resource maker template now injects NormalizerInterface
    (not SerializerInterface — that interface lacks ::normalize()).
    All Todo controllers re-rendered to match.
  - The command maker template emits a $this->em->flush() so the
    injected EntityManager isn't a property.onlyWritten violation
    in PHPStan after the user fills in the body.
  - phpstan.neon and php-cs-fixer's Finder both exclude tests/snapshot
    so the baselines aren't auto-rewritten or analysed as live code.

CI workflow now also installs FrankenPHP, builds the todo example, and
runs the bridge-integration test from Phase 3 sub-commit 4.

Phase 3 done. Outstanding follow-ups (deferred per spec): the
qmltestrunner-driven QML unit tests, make:bridge:event,
make:bridge:read-model, ReactiveObject pagination.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 16:03:41 +02:00
parent 1288a960d4
commit adc0cdc11d
15 changed files with 366 additions and 20 deletions

View File

@@ -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)

View File

@@ -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/*

View File

@@ -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');
}
}

View File

@@ -27,7 +27,9 @@ final class <?= $controller_short ?>
{
// TODO: implement <?= $singular ?>.
// 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]);
}

View File

@@ -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 <?= $singular ?> bridge resource.
@@ -21,7 +21,7 @@ final class <?= $entity_short ?>Controller
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly SerializerInterface $serializer,
private readonly NormalizerInterface $normalizer,
) {
}
@@ -30,7 +30,7 @@ final class <?= $entity_short ?>Controller
{
$items = $this->em->getRepository(<?= $entity_short ?>::class)->findAll();
return new JsonResponse($this->serializer->normalize($items, 'json'));
return new JsonResponse($this->normalizer->normalize($items, 'json'));
}
#[Route('', name: '<?= $resource ?>_create', methods: ['POST'])]
@@ -49,7 +49,7 @@ final class <?= $entity_short ?>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 <?= $entity_short ?>Controller
$this->em->flush();
return new JsonResponse($this->serializer->normalize($entity, 'json'));
return new JsonResponse($this->normalizer->normalize($entity, 'json'));
}
#[Route('/{id}', name: '<?= $resource ?>_delete', methods: ['DELETE'])]

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
/**
* Auto-generated domain command endpoint. Fill in the body and persist
* via $this->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]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use PhpQml\Bridge\Attribute\BridgeResource;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[BridgeResource(name: 'todo')]
class Todo
{
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
private Uuid $id;
#[ORM\Column(length: 255)]
private string $title = '';
#[ORM\Column]
private bool $done = false;
public function __construct()
{
$this->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;
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Todo;
use Doctrine\ORM\EntityManagerInterface;
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\Normalizer\NormalizerInterface;
/**
* Auto-generated CRUD controller for the Todo bridge resource.
* Edit freely — re-running make:bridge:resource won't overwrite this file.
*/
#[Route('/api/todos')]
final class TodoController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly NormalizerInterface $normalizer,
) {
}
#[Route('', name: 'todo_list', methods: ['GET'])]
public function list(): JsonResponse
{
$items = $this->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);
}
}

View File

@@ -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
}
}

View File

@@ -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 }
}
}
}

View File

@@ -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."

View File

@@ -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

View File

@@ -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'])]