Repository navigation
Testing Caligrafy
This page is for contributors to Caligrafy Quill: how the framework is tested, how to run all or part of the tests, how to add a test, and how a release is prepared. You do not need any of it to build an application with Caligrafy. How the framework itself is built (the request lifecycle, capabilities, the Context, the design decisions) is in ARCHITECTURE.md.
Run every command from the root of a clean checkout of the framework (git clone https://github.com/caligrafy/caligrafy-quill.git), not from one of your projects.
| Unit tests | QA suite | |
|---|---|---|
| Where | qa/unit |
qa/run.sh, qa/tests, qa/fixtures
|
| What | The core classes, one at a time | The whole framework: database, HTTP requests through Apache, Caligrafer, client apps, the Claude plugin |
| Needs | PHP 8.3+ and composer install
|
Docker, PHP with curl on the host, Node.js and composer install
|
| Time | A few seconds (333 tests) | Several minutes (the first run builds a Docker image) |
| Command | vendor/bin/phpunit |
./qa/run.sh |
Run the unit tests while you work, and the QA suite before you open a pull request. When you change framework/ or framework/settings/application, add a test for the change.
composer install
./qa/run.sh # builds the stack, runs every phase, removes everything
./qa/run.sh --keep # leaves the stack and the generated files in place, for debuggingqa/run.sh starts its own Docker stack (qa/docker-compose.yml, compose project quillqa): the web server built from docker/Dockerfile (stage dev-php, with the checkout mounted into it) on the first free port from 8090, MySQL 8.4 on the first free port from 3316, and Stripe's official mock (stripe/stripe-mock). The ports never clash with your development server (8080, 3306); set them with QA_WEB_PORT and QA_DB_PORT. No API key is needed: OpenAI, Groq and the other Agent providers, Coinbase, Watson and SMTP are replaced by small mock servers from qa/fixtures.
Before the phases, it generates what a project needs and removes it at the end: a .env from framework/settings/.env.example (marked # QA-GENERATED, with DB_HOST=mysql and the database quillqa), an application folder copied from the templates (marked with application/.qa-generated) plus the fixtures of qa/fixtures/application and qa/fixtures/facelogin, the QA routes added to web.php (with every commented route of the template turned on, and the 2.19 declarative routes), the client apps public/qaapp, public/qabot, public/qafacedetect and public/qafacelogin, and the tables of models/Auth/create_db.sql, qa/fixtures/books.sql, the face login fixture and platform install. Inside the web container it also serves the Watson mock (port 8769) and the push service mock (port 8770).
The suite refuses to run when an
applicationfolder or a.envfile that it did not create is present: move them aside or use a clean checkout. Note that every Caligrafer command exceptversionanddoctorcreates.envfrom the template when it is missing: delete that copy before you run the suite.
| Phase | Runs | Covers |
|---|---|---|
| Database scripts |
mysql in the mysql container |
models/Auth/create_db.sql can be run again without deleting users; platform install can be run again |
| Framework tests |
qa/tests/cli_tests.php in the web container |
Models (CRUD with falsy values, SQL injection, relationships and pivots), validation, the database connection, helpers, OpenAI request building, the Assistant on the Responses and Conversations APIs, Stripe against stripe-mock, Coinbase Business with real JWT signatures, the Groq Agent and the other Agent providers, Watson |
| Unit tests |
vendor/bin/phpunit --colors=never --do-not-cache-result in the web container |
The PHPUnit tests of qa/unit (see below) |
| HTTP tests |
qa/tests/http_tests.php on the host, against http://localhost:8090 (or the next free port) |
The front controller, the web app manifest and service worker, server files that must not be served, client apps (?app=), CSRF and API access, the REST API example of the wiki, uploads, the authentication module, the AI controller, face login with liveness and passkeys, the declarative routes (2.19.0: Route::auth, Route::form, file inputs, Route::webhook, Route::download; 2.19.1: the POST /logout form), MCP without a token (401, 2.19.1), backups/ denied (2.19.1), HTTP status codes |
| Doctor tests | qa/tests/doctor_tests.sh |
php caligrafer.php doctor against the mocks: good and wrong keys, unknown models, leading spaces, DNS errors, Agent providers, masked secrets |
| FileInfo hosts |
qa/run.sh itself |
The .htaccess rules on a host that only allows AllowOverride FileInfo: pages still load, server files and uploaded scripts answer 403, sw.js keeps Service-Worker-Allowed and the manifest its type |
| Tooling tests |
qa/tests/tooling_tests.sh on the host |
php -l on the framework, caligrafer initialize and upgrade on an older project, the client app scaffolds, the liveness logic (node qa/tests/liveness_tests.js), front-end libraries (pinned and reachable CDN URLs, no jQuery, no Bootstrap 3), the Bootstrap 5 compatibility layer of theme.css, .env templates, the web app manifest, the Vue SPA template (build, ESLint, npm audit; since 2.19.1 relative paths, hash history, no API key in the bundle, dist/ kept in git), the project's .gitignore (every .env.* but .env.example) and migrate --env <file> (2.19.1), the Claude plugin |
The suite exits with 0 when every phase passes. Each check prints PASS or FAIL with the reason.
| To run | Command |
|---|---|
| The unit tests only |
vendor/bin/phpunit (no Docker) |
| The tooling tests only |
sh qa/tests/tooling_tests.sh (no Docker; add QA_SKIP_SPA=1 to skip the Vue SPA build, which needs npm and the network). It ends with <n> passed, <n> failed
|
| The liveness and passkey client tests | node qa/tests/liveness_tests.js |
| A phase that needs the stack |
./qa/run.sh --keep once, then for example docker compose -f qa/docker-compose.yml exec -T web php qa/tests/cli_tests.php or php qa/tests/http_tests.php; clean up with docker compose -f qa/docker-compose.yml down -v and remove the generated files |
| Variable | Effect |
|---|---|
QA_WIKI=<wiki clone> |
The tooling phase also checks that the wiki mirrors of the Claude plugin are up to date with that clone (sync-wiki.py --check). Without it, the check is skipped |
QA_NO_BUILD=1 |
Reuses the existing quillqa-web image instead of rebuilding it. Use it on networks where the image build fails |
QA_SKIP_SPA=1 |
Skips the Vue SPA build of the tooling phase |
QA_WEB_PORT, QA_DB_PORT
|
Ports of the stack (default: the first free ports from 8090 and 3316) |
The tooling phase runs claude plugin validate only when the claude command is installed, and then also checks that Claude Code runs the plugin version of the checkout.
For example, before a release:
git clone https://github.com/caligrafy/caligrafy-quill.wiki.git ../caligrafy-quill.wiki
QA_WIKI=../caligrafy-quill.wiki ./qa/run.shThe stack always has the same compose project name (quillqa) and ports, and the suite writes .env, application and the public/qa* apps into the checkout. Two runs at the same time, even from two checkouts, share the same containers: one run tears down the stack of the other. Check that no QA stack is running before you start one:
docker ps --filter name=quillqaOn some slow or proxied networks, apt-get install in docker/Dockerfile grows to several GB while it retries and the build is killed ("Killed", "cannot allocate memory"). Give Docker more memory, close other containers, or build on another network. Once a quillqa-web image exists, run the suite with QA_NO_BUILD=1 ./qa/run.sh.
The HTTP phase proves that one capability behaves the same on every channel, with the Bookshelf capabilities of qa/fixtures/application/services/Books: a book created on a web page (BookPageController, Controller::call()) is the same through REST (Route::capability), the generic endpoint, MCP over HTTP and stdio, and the generated JS client run in Node; invalid input gets the same field errors everywhere; a token without the scope, or an owner without the level, is refused on REST and MCP alike; and the audit log names each channel. Tokens are created with php caligrafer.php token create inside the web container, like a user would.
composer install # installs PHPUnit 12 (require-dev)
vendor/bin/phpunit # every test of qa/unit
vendor/bin/phpunit qa/unit/RouteTest.php # one file
vendor/bin/phpunit --filter testBase64Url # one testThe configuration is phpunit.xml.dist: test suite unit (the folder qa/unit), bootstrap qa/unit/bootstrap.php, and warnings or risky tests make the run fail.
| File | Covers |
|---|---|
RouteTest.php |
Route::resolveController(): a global class wins over the example namespace, namespaced controllers with or without a leading backslash, short names that fall back to Caligrafy\Controllers, unknown controllers |
ErrorsTest.php |
Errors::status() (404, 401, 403, 500), Errors::raise(), the JSON error body for API requests and the plain text for HTML requests |
RequestTest.php |
Request::wantsJson(), the API key check (authorizeApiRequest()) with valid, invalid and missing keys, the session check (authorizeSessionRequest(), the X-CSRF-Token header) and the reason of a refusal (apiRefusal()); forClient(), the env.request of client apps without the Cookie, the Authorization header or a POST body (2.19.1) |
ModelTest.php |
prepareInputs() keeps 0, '' and false and drops null and restricted properties; identifier quoting accepts safe names and rejects unsafe ones |
ControllerTest.php |
validate() and filter() (GUMP) |
UpgraderTest.php |
caligrafer upgrade in a temporary project: the classification of files, the template history, --apply only replaces untouched files and never touches .env
|
DoctorTest.php |
Doctor::mask() and the key format checks (leading space, missing dash after the prefix) |
DoctorWebAppTest.php |
The install to home screen check of doctor: the framework manifest, missing or invalid manifests, missing icons, required sizes and fields, the service worker check only when PWA_SERVICE_WORKER is on |
AgentTest.php |
Agent::settings(): Groq is the default, the GROQ_* and AGENT_* settings, key precedence, the defaults of every provider, Ollama without a key, unknown providers |
FaceLoginTest.php |
FaceLogin: random challenges kept in the session, a nonce accepted once (even when the check fails), expiry, wrong nonce or account, challenge order, identical prints, prints of different faces, print format and count, account matching, print hashes |
PasskeyTest.php |
Passkey against a software authenticator: registration options, register and authenticate, excluded credentials, replayed assertions, single-use challenges, wrong origin, bad signature, sign counter going back, another user's passkey, user verification, relying party |
CapabilitiesTest.php |
The registry of capabilities: attributes, checks of scope and level, validation, the audit, Capabilities::call()
|
ContextTest.php |
Context: the CLI, guests, users, tokens and system contexts, scopes and levels |
TokensTest.php |
Personal access tokens: format, hashing, scopes, expiry, revocation |
ClientTest.php, McpServerTest.php
|
The generated JS client, and the MCP server (tools listed by scope, calls and errors; a POST without a token gets the JSON-RPC 401, not the CSRF error, 2.19.1) |
DeployerTest.php |
php caligrafer.php deploy: which create_db.sql scripts run, the destructive ones never twice |
SchedulerTest.php |
Scheduled jobs: the declaration of schedule.php, cron, the listing Caligrafy Cloud reads |
NotifyTest.php |
Notifications without a database: message rules, channels, keys, the signed unsubscribe link |
CloudClientTest.php, UpdaterTest.php, QuickstartTest.php, PluginSourceTest.php, VersionTest.php, DesignsTest.php
|
The cloud, update, quickstart, plugin, version and design commands |
DoctorFormsTest.php, DoctorPugTest.php
|
Doctor's checks of forms without a method and of two Pug mistakes |
ViewTest.php |
The compiled view cache (2.17.0): a private folder, an edited view compiled again |
ClassMapTest.php |
The class map (2.18.0): declarations found, classes loaded when used, files that run at start, a new class found at once |
MigrationsTest.php |
Migrations (2.18.0): how a script is split, the order and names of the files, make
|
ContainerTest.php |
The container (2.18.0): constructor injection, bindings, singletons, instances, clear errors |
CacheTest.php |
The cache (2.18.0): the default store, JSON values, expiry, remember, off
|
ValidatorRulesTest.php |
The exists, unique and current_year rules (2.19.0): the ignored row of unique, names checked like the query builder, the app's own message, their use in services, a schema rule without its argument |
TestCaseTest.php |
Caligrafy\Testing\TestCase: each test starts without the queries of earlier tests (no N+1 warning carried over) |
QueryTest.php, QueryModelTest.php, QueryEagerTest.php, DatabaseQueryTest.php
|
Queries (2.19.0): the SQL and bindings of the query builder, validated names, writes without a where, Book::query() and the model forms, eager loading (with, withCount, pivots), and on an in-memory SQLite the transactions and the N+1 warning. php qa/bench-query.php (not part of the run) times 10,000 queries |
SessionLazyTest.php |
Sessions only when used (2.19.0): what starts a session, and when a request without a session cookie saves one |
FormRouteTest.php, UploadsTest.php, AuthRoutesTest.php, WebhookTest.php, DownloadTest.php
|
The declarative routes (2.19.0): Route::form (redirects, errors, old input, flash), the file rule (required_file and required|file refuse a missing file, 2.19.1), Route::auth (sign-in, lock, session regeneration, the POST /logout form, 2.19.1), Route::webhook (signatures), Route::download (CSV, file names) |
The unit tests run without a web server, a database or a .env. qa/unit/bootstrap.php does only what the tested classes need:
- defines a test
APP_KEY(constant and environment variable), - requires
vendor/autoload.phpandframework/helpers/corehelpers.php, - requires the core classes and interfaces under test one by one (the list is in
qa/unit/bootstrap.php:Errors,Route,Request,Model...ClassMap,View,Migrations,Container,Cache,Query,Form,Uploads,AuthRoutes,Webhook,Download...,Rest,Mcp/Server,Mcp/Http,Clientandframework/database/Database.php, withDB_ACTIVATE=false: the query tests giveDatabase::useConnection()an in-memory SQLite), - requires the fixtures of
qa/unit/fixtures: test controllers in the global,ShopandCaligrafy\Controllersnamespaces,QaSoftAuthenticator, a software WebAuthn authenticator (EC P-256, "none" attestation) also used by the HTTP tests, the test capabilities ofservices.php, andquery.php(an executor that records the queries of the query builder, and test models).
It never calls Framework::init(): no route is dispatched. Classes whose constructor needs the live request or the database are built without their constructor (ReflectionClass::newInstanceWithoutConstructor()), and the tests set the properties they need. Passkey keeps its credentials in memory by overriding its 4 storage methods. Tests that need files (UpgraderTest, DoctorWebAppTest) create a temporary project and delete it in tearDown().
tests/ belongs to the applications: since 2.18.0 it holds an app's own tests (php caligrafer.php test, Testing your app), and projects keep it when they update. The framework's unit tests therefore live in qa/unit, which the framework never loads (and which .htaccess denies to browsers). Before 2.18.0 the framework also loaded every PHP file of tests/ on every request.
- Create
qa/unit/<Something>Test.phpwith afinal class <Something>Test extends TestCase(use PHPUnit\Framework\TestCase;) andpublic function test...(): voidmethods. - If the class under test is not loaded yet, add it to the list of
qa/unit/bootstrap.php. - Keep it independent of the network, the database and
.env: build objects without their constructor when it reads the request or connects, restore every superglobal ($_SERVER,$_SESSION) and setting you change intearDown(). - Run
vendor/bin/phpunit.
<?php
use Caligrafy\Errors;
use PHPUnit\Framework\TestCase;
final class ErrorsExampleTest extends TestCase
{
public function testCsrfErrorIsForbidden(): void
{
$this->assertSame(403, Errors::status(3000));
}
}| Fixture | Used for |
|---|---|
openai_mock.php |
OpenAI Responses, Conversations, Files, Vector Stores and models endpoints (Assistant, doctor). Every request is logged to qa_openai_mock.log in the temporary folder, so a test can check what was sent |
groq_mock.php |
The OpenAI-compatible endpoints of Agent and doctor. The first path segment picks the provider: /openai/v1 (Groq), /together/v1, /fireworks/inference/v1, /openrouter/api/v1, /ollama/v1, /ollama-empty/v1 (no model pulled), /custom/v1
|
coinbase_mock.php |
Coinbase Business Checkouts API. It logs each request with its Authorization header to qa_coinbase_mock.log, and cli_tests.php verifies the JWT signatures |
watson_mock.php |
watsonx Assistant v2 (sessions and messages) |
smtp_mock.php |
An SMTP greeter for the doctor mail check |
push_mock.php |
A web push service for the notifications tests: /ok/* accepts (201), /gone/* answers 410, /fail/* 500; each accepted push is logged with its headers |
echo.php |
Answers with the request it received (method, URI, body, Authorization header) to inspect outgoing calls such as httpRequest()
|
application/ |
QA code copied into the generated application: QaController (errors, uploads, the install module), BookController and Book (the REST API example of the wiki), BookPageController (a page calling a capability), a project AIController (a name that collides with an example controller), Shop/OrderController (namespaced controller), the Bookshelf capabilities of services/Books, the jobs of services/QaJobs, the capabilities of the declarative routes in services/QaRoutes, and the Qa views |
books.sql |
Tables of the REST API example |
facelogin/ |
The face login example of the recipe, with the controllers FaceLoginController and PasskeyController (the recipe itself now declares these addresses with Route::action), the tables (create_db.sql) and the client app |
docs/ |
The components example of the Vue pages of the wiki |
outside/ |
A file outside the plugin, for the php -l hook test |
The mocks are plain PHP scripts served with php -S inside the web container (for example php -S 127.0.0.1:8766 qa/fixtures/openai_mock.php), and the tests point the framework to them with the base URL settings (OPENAI_API_BASE, GROQ_API_BASE, AGENT_API_BASE, STRIPE_API_BASE, COINBASE_API_BASE, WATSON_URL).
Choose the phase by what the check needs:
-
Framework code that needs the database or a mock (models, payments, AI request building): add a
qa_test()toqa/tests/cli_tests.php, in the rightqa_section(). The helpers ofqa/tests/lib.phpareqa_test($name, $fn),qa_assert($condition, $message),qa_equals($expected, $actual),qa_contains($needle, $haystack)andqa_not_contains().qa_test('endWith() compares the whole needle', function () { qa_assert(endWith('caligrafy.php', '.php')); qa_assert(!endWith('caligrafy.php', 'xphp')); });
-
Behavior seen by a browser or an API client (status codes, headers, routes): add a
qa_test()toqa/tests/http_tests.php. If it needs a route, add it to the routes thatqa/run.shinserts intoweb.php, and its controller toqa/fixtures/application/controllers. -
Files, Caligrafer commands, scaffolds, front-end libraries, the plugin: add an
ok/kocheck toqa/tests/tooling_tests.sh. It runs on the host and works inqa/tmp. -
doctor: add a variant
.envand a check toqa/tests/doctor_tests.sh.
Make the check fail first (break the code it protects), then make it pass. Never print a secret in a check message.
With Claude Code in the framework repository, the caligrafy-contributor skill (.claude/skills/caligrafy-contributor/SKILL.md) follows this process after every framework change, then asks whether to review the course and whether to update and deploy caligrafy.com and Caligrafy Cloud, and ends with a report (plugin, README and wiki, course, and an inventory of every Caligrafy folder with its versions). It is for contributors only: releases leave it out (.gitattributes), php caligrafer.php update never adds it to a project, and php caligrafer.php initialize deletes it from a clone.
The wiki clone is ../caligrafy-quill.wiki, next to the framework checkout (git clone https://github.com/caligrafy/caligrafy-quill.wiki.git ../caligrafy-quill.wiki).
-
Claude plugin. Update what the change touches in
claude-plugin/:skills/caligrafy/reference/api-reference.mdandgotchas.md(written by hand from the source: a new behavior goes in the API reference, a pitfall found becomes the nextG<n>, and the rangeG1-G<n>ofskills/caligrafy/SKILL.mdfollows),ui-guidelines.mdwhen the look changes, every other skill and thecaligrafy-revieweragent, read in full against the change. -
README and root documents.
README.md(keep its banner),ARCHITECTURE.md,AGENTS.md,CLAUDE.md,CONTRIBUTING.md,claude-plugin/README.md: change what the change makes wrong or incomplete. -
Wiki. Every page the change affects, read in full. A minor or major release gets a page
Release-notes:-X.Y.0.md, linked in_Sidebar.md; a patch release gets a shortRelease-notes:-X.Y.Z.mdtoo (php caligrafer.php updatelinks to the release notes of the exact version it installs). The banner image is only onHome.md. Then refresh the plugin's mirrors of the wiki:A new wiki page needs a line in thepython3 claude-plugin/scripts/sync-wiki.py ../caligrafy-quill.wiki # rewrite the mirrors python3 claude-plugin/scripts/sync-wiki.py ../caligrafy-quill.wiki --check # exit 1 when a mirror is stale
PAGEStable ofsync-wiki.pyand a row in the reference map ofskills/caligrafy/SKILL.md. -
Plugin version. Bump
versioninclaude-plugin/.claude-plugin/plugin.json(minor with a framework minor, patch otherwise), add a line at the top of the changelog ofclaude-plugin/README.md, update the "Current version" line of the Claude Code plugin page, and validate:The tooling phase fails while Claude Code runs an older copy than the checkout's plugin.claude plugin validate ./claude-plugin --strict claude plugin validate . --strict # the marketplace php caligrafer.php plugin # refreshes Claude Code's copy of the plugin in this checkout
-
Framework version. Set
Caligrafy\Framework::VERSIONinframework/core/Framework.php(semantic versioning: major when projects can break, minor for features, patch for fixes) and add its section at the top ofCHANGELOG.md. TheVersionTestunit test checks the format and the changelog. -
Templates changed? When a file of
framework/settings/applicationchanged, rebuild the template history:php qa/build-template-history.php
framework/settings/template-history.jsonholds the sha256 of every version each template file had in the git history (and the working tree).php caligrafer.php upgradeuses it to recognize untouched copies in projects that have noapplication/.caligrafy-manifest.json. The tooling phase fails when a current template version is missing from it. -
Run the whole suite with the wiki clone:
Before the release is tagged, the only expected failure is
QA_WIKI=../caligrafy-quill.wiki ./qa/run.sh
caligrafer version(it compares the version with the tag on the commit). After the tag,sh qa/tests/tooling_tests.shmust pass entirely. -
Commit, tag and push. Commit only the files of the change (see
CONTRIBUTING.md), then tag the release and pushmaster, the tag and the wiki. Packagist picks the tag up:git tag -a v2.19.0 -m "Caligrafy Quill 2.19.0" git push origin master v2.19.0 git -C ../caligrafy-quill.wiki push -
GitHub release. Create it from the tag with its
CHANGELOG.mdsection as its notes (it is public: the maintainer decides). Until it exists,php caligrafer.php updatewithout a version does not offer the release:gh release create v2.19.0 --title "Caligrafy Quill 2.19.0" --notes-file <its CHANGELOG section> --latest
- Release notes: 2.19.0
- Testing your app: the tests of an application, not of the framework
- Claude Code plugin
- ARCHITECTURE.md
Caligrafy: Describe it. Approve it. Ship it. Your idea turned into a complete app: every feature built once, for web pages, Vue apps, a REST API and AI agents, and online.
-
Quickstart with Claude: describe it, approve it, ship it
or the Quickstart: the same three steps by hand, in 5 minutes
- How Caligrafy works: the ideas in 5 minutes
- Your first capability: build a feature, page included
-
Capabilities in depth
- Scheduled jobs: capabilities on a timer
- Notifications: web push and email
- Background jobs: capabilities run later
- Migrations: database changes, in order
- The cache
- Testing your app
- Dependency injection
- Validation rules
-
Web pages
-
Vue apps
-
Mobile and tablet
-
REST API
-
AI agents
- Models
- Relationships
- Queries: filters, paging, related rows
- Sign-in, users and tokens · with an email
- Payments: Stripe and crypto
- Face detection and face login
- OpenAI: chat and images · Assistants
- Open models: Llama, Mistral...
- Chatbots with Watson
- Machine learning in the browser
- Metadata and social cards · search and analytics
-
Putting it online: the production image, the database at every start
- Caligrafer: every command
- Installation, configuration and Caligrafer
- Routing · Request · Controllers · Views
- Vue: flow of information · routes · validations
- Security and upgrading
- Release notes: 2.21 · 2.20 · 2.19 · 2.18 · 2.17 · 2.16 · 2.15 · 2.14 · 2.13 · 2.12 · 2.11 · 2.10 · 2.9 · 2.8 · 2.7 · 2.6 · 2.5 · 2.4 · 2.3 · 2.1 · October 2026 · changelog
- Testing Caligrafy (contributors)