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:
@@ -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)
|
||||
|
||||
@@ -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/*
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
35
framework/php/tests/snapshot/MarkAllDoneController.php
Normal file
35
framework/php/tests/snapshot/MarkAllDoneController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
54
framework/php/tests/snapshot/Todo.php
Normal file
54
framework/php/tests/snapshot/Todo.php
Normal 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;
|
||||
}
|
||||
}
|
||||
101
framework/php/tests/snapshot/TodoController.php
Normal file
101
framework/php/tests/snapshot/TodoController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
28
framework/php/tests/snapshot/TodoList.qml
Normal file
28
framework/php/tests/snapshot/TodoList.qml
Normal 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
|
||||
}
|
||||
}
|
||||
36
framework/php/tests/snapshot/TodoWindow.qml
Normal file
36
framework/php/tests/snapshot/TodoWindow.qml
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
63
framework/php/tests/snapshot/run.sh
Executable file
63
framework/php/tests/snapshot/run.sh
Executable 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."
|
||||
@@ -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
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
Reference in New Issue
Block a user