From 449e6f8e0350ef0e4c193b182650d17c3a2b857d Mon Sep 17 00:00:00 2001
From: Ruben
Date: Tue, 17 Mar 2026 12:51:07 +0100
Subject: [PATCH] Update framework testing infrastructure and standards
- Add phpt test runner and suite for app functions
- Introduce testing workflow to AGENT.md
- Add tests for cache, content, context, helpers, hooks, plugins,
rendering
- Mount tests directory in dev container
---
AGENT.md | 16 +--
devel/compose.yaml | 1 +
devel/tests/cache/build_cache_key.phpt | 49 ++++++++
.../tests/cache/get_set_cached_markdown.phpt | 36 ++++++
.../tests/content/find_all_content_files.phpt | 47 ++++++++
devel/tests/content/load_metadata.phpt | 44 +++++++
.../tests/content/resolve_slug_to_folder.phpt | 48 ++++++++
devel/tests/context/context_data.phpt | 54 +++++++++
.../helpers/extract_meta_description.phpt | 45 +++++++
.../helpers/extract_raw_date_from_folder.phpt | 20 ++++
devel/tests/helpers/extract_title.phpt | 49 ++++++++
devel/tests/helpers/find_cover_image.phpt | 33 ++++++
devel/tests/helpers/find_page_css.phpt | 42 +++++++
devel/tests/helpers/find_page_js.phpt | 36 ++++++
devel/tests/helpers/find_pdf_file.phpt | 28 +++++
devel/tests/helpers/get_subdirectories.phpt | 39 ++++++
devel/tests/hooks/hooks_add_apply.phpt | 38 ++++++
devel/tests/plugins/plugin_manager.phpt | 59 +++++++++
.../tests/rendering/render_content_file.phpt | 39 ++++++
devel/tests/run.php | 112 ++++++++++++++++++
devel/tests/run.sh | 8 ++
docs/05-testing/01-testing.md | 73 ++++++++++++
22 files changed, 909 insertions(+), 7 deletions(-)
create mode 100644 devel/tests/cache/build_cache_key.phpt
create mode 100644 devel/tests/cache/get_set_cached_markdown.phpt
create mode 100644 devel/tests/content/find_all_content_files.phpt
create mode 100644 devel/tests/content/load_metadata.phpt
create mode 100644 devel/tests/content/resolve_slug_to_folder.phpt
create mode 100644 devel/tests/context/context_data.phpt
create mode 100644 devel/tests/helpers/extract_meta_description.phpt
create mode 100644 devel/tests/helpers/extract_raw_date_from_folder.phpt
create mode 100644 devel/tests/helpers/extract_title.phpt
create mode 100644 devel/tests/helpers/find_cover_image.phpt
create mode 100644 devel/tests/helpers/find_page_css.phpt
create mode 100644 devel/tests/helpers/find_page_js.phpt
create mode 100644 devel/tests/helpers/find_pdf_file.phpt
create mode 100644 devel/tests/helpers/get_subdirectories.phpt
create mode 100644 devel/tests/hooks/hooks_add_apply.phpt
create mode 100644 devel/tests/plugins/plugin_manager.phpt
create mode 100644 devel/tests/rendering/render_content_file.phpt
create mode 100644 devel/tests/run.php
create mode 100755 devel/tests/run.sh
create mode 100644 docs/05-testing/01-testing.md
diff --git a/AGENT.md b/AGENT.md
index 53d6dc2..bc8ea1d 100644
--- a/AGENT.md
+++ b/AGENT.md
@@ -11,16 +11,17 @@ Decade-scale maintainability. Only essential tech. Readable, simple, future-proo
1. **Building on top** (`custom/`): Create sites using the framework. Never modify `app/`. In this mode, `app/` is typically symlinked or submoduled from the framework repo into a separate site repo.
2. **Framework development** (`app/`): Evolve the core. Preserve all stable contracts (see architecture doc). `custom/` may be symlinked in from a site repo for testing.
-## Core Constraints
+## Framework Development Workflow
+
+When modifying or adding code in `app/`: write tests and run them before marking work done. Read `docs/05-testing/01-testing.md` for the required workflow and test format.
+
+## Standards
- **Stack:** HTML5, PHP 8.4+, CSS. Nothing else.
-- **Frontend:** Classless semantic HTML, modern CSS (nesting, `oklch()`, grid, `clamp()`, logical props)
- **Security:** Path traversal protection, document root restriction, strict MIME types, escape all UGC
-
-## Code Style
-
-- **PHP:** Arrow functions, null coalescing, match expressions. Type hints where practical. Single-purpose functions. Comments only for major sections.
-- **CSS:** Variables, native nesting, grid. `clamp()` over `@media`. Relative units.
+- **PHP:** Arrow functions, null coalescing, match expressions. Type hints where practical. Single-purpose functions, avoid side effects. Comments only for major sections.
+- **HTML:** Classless, semantic markup.
+- **CSS:** Variables, native nesting, relative units. Modern CSS (`oklch()`, `light-dark()`, `clamp()`, logical props). Global styles first, page-scoped when needed.
- **Templates:** `= htmlspecialchars($var) ?>` for UGC. `= $content ?>` for pre-rendered HTML.
## Knowledge Base
@@ -37,5 +38,6 @@ Read these docs on-demand when working on related areas. Do not load all at once
| Templates | `docs/04-development/06-templates.md` | Template hierarchy, resolution, variables, list item schema, partials |
| Rendering | `docs/04-development/07-rendering.md` | Rendering pipeline, Markdown caching, static file serving, Parsedown |
| Dev Environment | `docs/04-development/08-dev-environment.md` | Container setup, Apache config, performance profiling, test data generation |
+| Testing | `docs/05-testing/01-testing.md` | **Required before modifying `app/`** — workflow, test format, what's testable |
Human-facing docs (tutorials, reference) are in `docs/01-getting-started/`, `docs/02-tutorial/`, `docs/03-reference/`.
diff --git a/devel/compose.yaml b/devel/compose.yaml
index 506f507..947818c 100644
--- a/devel/compose.yaml
+++ b/devel/compose.yaml
@@ -9,6 +9,7 @@ services:
- ../app:/var/www/app:z
- ./apache/custom.conf:/etc/apache2/conf-available/custom.conf:z
- ./apache/default.conf:/etc/apache2/sites-available/000-default.conf:z
+ - ./tests:/var/www/tests:z
ports:
- "8080:80"
command: bash -c "a2enconf custom && a2enmod rewrite && apache2-foreground"
diff --git a/devel/tests/cache/build_cache_key.phpt b/devel/tests/cache/build_cache_key.phpt
new file mode 100644
index 0000000..a85ca4b
--- /dev/null
+++ b/devel/tests/cache/build_cache_key.phpt
@@ -0,0 +1,49 @@
+--TEST--
+buildCacheKey: returns md5 string, varies by path and langPrefix
+--FILE--
+
+--EXPECT--
+valid md5
+stable
+prefix differs
+path differs
+meta invalidates
diff --git a/devel/tests/cache/get_set_cached_markdown.phpt b/devel/tests/cache/get_set_cached_markdown.phpt
new file mode 100644
index 0000000..8239c03
--- /dev/null
+++ b/devel/tests/cache/get_set_cached_markdown.phpt
@@ -0,0 +1,36 @@
+--TEST--
+getCachedMarkdown / setCachedMarkdown: store and retrieve rendered HTML by file key
+--FILE--
+Hello');
+echo getCachedMarkdown($file) . "\n";
+
+// langPrefix produces a separate cache entry
+echo (getCachedMarkdown($file, '/fr') ?? 'null') . "\n";
+
+setCachedMarkdown($file, 'Bonjour
', '/fr');
+echo getCachedMarkdown($file, '/fr') . "\n";
+
+// Default key is unaffected
+echo getCachedMarkdown($file) . "\n";
+
+unlink($file);
+rmdir($dir);
+?>
+--EXPECT--
+null
+Hello
+null
+Bonjour
+Hello
diff --git a/devel/tests/content/find_all_content_files.phpt b/devel/tests/content/find_all_content_files.phpt
new file mode 100644
index 0000000..639a60a
--- /dev/null
+++ b/devel/tests/content/find_all_content_files.phpt
@@ -0,0 +1,47 @@
+--TEST--
+findAllContentFiles: returns sorted content file paths, ignores non-content files and index.php
+--FILE--
+Hi
');
+file_put_contents("$dir/script.php", '
+--EXPECT--
+0
+0
+3
+extra.html
+page.md
+script.php
diff --git a/devel/tests/content/load_metadata.phpt b/devel/tests/content/load_metadata.phpt
new file mode 100644
index 0000000..3cd6bce
--- /dev/null
+++ b/devel/tests/content/load_metadata.phpt
@@ -0,0 +1,44 @@
+--TEST--
+loadMetadata: parses metadata.ini, returns null when absent
+--FILE--
+
+--EXPECT--
+null
+My Page
+A summary
+1
+raw present
+section contained
+English Title
+French Title
diff --git a/devel/tests/content/resolve_slug_to_folder.phpt b/devel/tests/content/resolve_slug_to_folder.phpt
new file mode 100644
index 0000000..8596f00
--- /dev/null
+++ b/devel/tests/content/resolve_slug_to_folder.phpt
@@ -0,0 +1,48 @@
+--TEST--
+resolveSlugToFolder: resolves URL slug to actual folder name, supports metadata slug override
+--FILE--
+
+--EXPECT--
+null
+my-post
+null
+2024-01-15-article
+2024-01-15-article
+my-post
diff --git a/devel/tests/context/context_data.phpt b/devel/tests/context/context_data.phpt
new file mode 100644
index 0000000..7d088e3
--- /dev/null
+++ b/devel/tests/context/context_data.phpt
@@ -0,0 +1,54 @@
+--TEST--
+Context: set/get/has and magic property access for plugin data storage
+--FILE--
+requestPath . "\n";
+echo $ctx->contentDir . "\n";
+echo ($ctx->hasTrailingSlash ? 'true' : 'false') . "\n";
+
+// set/get/has
+$ctx->set('langPrefix', '/fr');
+echo $ctx->get('langPrefix') . "\n";
+echo ($ctx->has('langPrefix') ? 'yes' : 'no') . "\n";
+echo ($ctx->has('missing') ? 'yes' : 'no') . "\n";
+
+// get with default
+echo $ctx->get('nope', 'fallback') . "\n";
+
+// Magic __get / __set
+$ctx->customKey = 'magic-value';
+echo $ctx->customKey . "\n";
+
+// Magic __get for undefined key returns null
+echo ($ctx->undefined ?? 'null') . "\n";
+
+// set and magic access are the same store
+$ctx->set('shared', 'data');
+echo $ctx->shared . "\n";
+
+$ctx->magic2 = 'x';
+echo $ctx->get('magic2') . "\n";
+?>
+--EXPECT--
+blog/post
+/tmp/content
+true
+/fr
+yes
+no
+fallback
+magic-value
+null
+data
+x
diff --git a/devel/tests/helpers/extract_meta_description.phpt b/devel/tests/helpers/extract_meta_description.phpt
new file mode 100644
index 0000000..9e02756
--- /dev/null
+++ b/devel/tests/helpers/extract_meta_description.phpt
@@ -0,0 +1,45 @@
+--TEST--
+extractMetaDescription: returns description from metadata fields or first content paragraph
+--FILE--
+ 'SEO description', 'summary' => 'Summary']) . "\n";
+
+// summary is fallback when no search_description
+echo extractMetaDescription($dir, ['summary' => 'Page summary']) . "\n";
+
+// null metadata, no content files
+echo (extractMetaDescription($dir, null) ?? 'null') . "\n";
+
+// First paragraph from markdown (skips headings and short lines)
+file_put_contents("$dir/index.md", "# Title\n\nThis is the opening paragraph of the post.\n\nSecond paragraph.");
+echo extractMetaDescription($dir, null) . "\n";
+unlink("$dir/index.md");
+
+// Heading-only markdown with no usable paragraph
+file_put_contents("$dir/index.md", "# Just a Heading\n\nShort.\n");
+echo (extractMetaDescription($dir, null) ?? 'null') . "\n";
+unlink("$dir/index.md");
+
+// First from HTML
+file_put_contents("$dir/index.html", "
Title
HTML paragraph text here.
");
+echo extractMetaDescription($dir, null) . "\n";
+unlink("$dir/index.html");
+
+rmdir($dir);
+?>
+--EXPECT--
+SEO description
+Page summary
+null
+This is the opening paragraph of the post.
+null
+HTML paragraph text here.
diff --git a/devel/tests/helpers/extract_raw_date_from_folder.phpt b/devel/tests/helpers/extract_raw_date_from_folder.phpt
new file mode 100644
index 0000000..1e12530
--- /dev/null
+++ b/devel/tests/helpers/extract_raw_date_from_folder.phpt
@@ -0,0 +1,20 @@
+--TEST--
+extractRawDateFromFolder: extracts YYYY-MM-DD from date-prefixed folder names
+--FILE--
+
+--EXPECT--
+2024-01-15
+2024-12-31
+null
+null
+null
+null
diff --git a/devel/tests/helpers/extract_title.phpt b/devel/tests/helpers/extract_title.phpt
new file mode 100644
index 0000000..c59ef4e
--- /dev/null
+++ b/devel/tests/helpers/extract_title.phpt
@@ -0,0 +1,49 @@
+--TEST--
+extractTitle: extracts title from # heading in .md or in .html
+--FILE--
+
+file_put_contents("$dir/index.html", "HTML Title
Body
");
+echo extractTitle($dir) . "\n";
+unlink("$dir/index.html");
+
+// HTML with attributes on h1
+file_put_contents("$dir/index.html", 'Styled Title
');
+echo extractTitle($dir) . "\n";
+unlink("$dir/index.html");
+
+// Markdown without heading
+file_put_contents("$dir/index.md", "Just a paragraph, no heading.");
+echo (extractTitle($dir) ?? 'null') . "\n";
+unlink("$dir/index.md");
+
+rmdir($dir);
+?>
+--EXPECT--
+null
+My Great Post
+Spaced Title
+HTML Title
+Styled Title
+null
diff --git a/devel/tests/helpers/find_cover_image.phpt b/devel/tests/helpers/find_cover_image.phpt
new file mode 100644
index 0000000..e9263c7
--- /dev/null
+++ b/devel/tests/helpers/find_cover_image.phpt
@@ -0,0 +1,33 @@
+--TEST--
+findCoverImage: finds cover.{ext} in directory, prefers first matching extension
+--FILE--
+
+--EXPECT--
+null
+cover.jpg
+cover.jpg
+cover.webp
diff --git a/devel/tests/helpers/find_page_css.phpt b/devel/tests/helpers/find_page_css.phpt
new file mode 100644
index 0000000..80cb75d
--- /dev/null
+++ b/devel/tests/helpers/find_page_css.phpt
@@ -0,0 +1,42 @@
+--TEST--
+findPageCss: returns URL and hash for styles.css, null when absent
+--FILE--
+
+--EXPECT--
+null
+/blog/post/styles.css
+hash ok
+/styles.css
+null
diff --git a/devel/tests/helpers/find_page_js.phpt b/devel/tests/helpers/find_page_js.phpt
new file mode 100644
index 0000000..f126a49
--- /dev/null
+++ b/devel/tests/helpers/find_page_js.phpt
@@ -0,0 +1,36 @@
+--TEST--
+findPageJs: returns URL and hash for script.js, null when absent
+--FILE--
+
+--EXPECT--
+null
+/section/page/script.js
+hash ok
+/script.js
diff --git a/devel/tests/helpers/find_pdf_file.phpt b/devel/tests/helpers/find_pdf_file.phpt
new file mode 100644
index 0000000..2f923d8
--- /dev/null
+++ b/devel/tests/helpers/find_pdf_file.phpt
@@ -0,0 +1,28 @@
+--TEST--
+findPdfFile: finds first .pdf file in a directory
+--FILE--
+
+--EXPECT--
+null
+document.pdf
+document.pdf
diff --git a/devel/tests/helpers/get_subdirectories.phpt b/devel/tests/helpers/get_subdirectories.phpt
new file mode 100644
index 0000000..4de57a2
--- /dev/null
+++ b/devel/tests/helpers/get_subdirectories.phpt
@@ -0,0 +1,39 @@
+--TEST--
+getSubdirectories: returns only subdirectory names, ignores files
+--FILE--
+
+--EXPECT--
+empty
+0
+alpha
+beta
+file excluded
diff --git a/devel/tests/hooks/hooks_add_apply.phpt b/devel/tests/hooks/hooks_add_apply.phpt
new file mode 100644
index 0000000..8ca291d
--- /dev/null
+++ b/devel/tests/hooks/hooks_add_apply.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Hooks: apply passes value through, add registers filters that chain in order
+--FILE--
+ strtoupper($v));
+echo Hooks::apply(Hook::PROCESS_CONTENT, 'hello') . "\n";
+
+// Filters chain: second receives output of first
+Hooks::add(Hook::PROCESS_CONTENT, fn($v) => $v . '!');
+echo Hooks::apply(Hook::PROCESS_CONTENT, 'hello') . "\n";
+
+// Other hooks are unaffected
+echo Hooks::apply(Hook::TEMPLATE_VARS, 'foo') . "\n";
+
+// Extra args are passed through to each filter
+Hooks::add(Hook::TEMPLATE_VARS, fn($v, $extra) => "$v:$extra");
+echo Hooks::apply(Hook::TEMPLATE_VARS, 'x', 'ctx') . "\n";
+
+// Non-string values pass through too
+$arr = ['a' => 1];
+echo Hooks::apply(Hook::CONTEXT_READY, $arr) === $arr ? 'array passthrough' : 'fail';
+echo "\n";
+?>
+--EXPECT--
+hello
+foo
+HELLO
+HELLO!
+foo
+x:ctx
+array passthrough
diff --git a/devel/tests/plugins/plugin_manager.phpt b/devel/tests/plugins/plugin_manager.phpt
new file mode 100644
index 0000000..36e55c9
--- /dev/null
+++ b/devel/tests/plugins/plugin_manager.phpt
@@ -0,0 +1,59 @@
+--TEST--
+PluginManager: tracks loaded plugins, deduplicate loads, respects scope
+--FILE--
+getLoadedPlugins()) . "\n";
+echo count($pm->getGlobalPlugins()) . "\n";
+echo ($pm->isLoaded('languages') ? 'yes' : 'no') . "\n";
+echo ($pm->getPluginInfo('languages') ?? 'null') . "\n";
+
+// loadPagePlugins with null — no-op
+$pm->loadPagePlugins(null);
+echo count($pm->getLoadedPlugins()) . "\n";
+
+// loadPagePlugins with no plugins key — no-op
+$pm->loadPagePlugins(['title' => 'My Page']);
+echo count($pm->getLoadedPlugins()) . "\n";
+
+// loadGlobalPlugins with no enabled key — no-op
+$pm->loadGlobalPlugins(['other' => 'stuff']);
+echo count($pm->getLoadedPlugins()) . "\n";
+
+// Load the built-in languages plugin via global config
+$pm->loadGlobalPlugins(['plugins' => ['enabled' => 'languages']]);
+echo ($pm->isLoaded('languages') ? 'yes' : 'no') . "\n";
+echo count($pm->getGlobalPlugins()) . "\n";
+echo in_array('languages', $pm->getGlobalPlugins()) ? 'in global' : 'not in global';
+echo "\n";
+
+// Loading again is a no-op (deduplication)
+$pm->loadGlobalPlugins(['plugins' => ['enabled' => 'languages']]);
+echo count($pm->getLoadedPlugins()) . "\n";
+
+// getPluginInfo returns path and scope
+$info = $pm->getPluginInfo('languages');
+echo ($info['scope'] === 'global' ? 'scope ok' : 'scope wrong') . "\n";
+echo (str_ends_with($info['path'], 'languages.php') ? 'path ok' : 'path wrong') . "\n";
+
+?>
+--EXPECT--
+0
+0
+no
+null
+0
+0
+0
+yes
+1
+in global
+1
+scope ok
+path ok
diff --git a/devel/tests/rendering/render_content_file.phpt b/devel/tests/rendering/render_content_file.phpt
new file mode 100644
index 0000000..e740750
--- /dev/null
+++ b/devel/tests/rendering/render_content_file.phpt
@@ -0,0 +1,39 @@
+--TEST--
+renderContentFile: renders .html by inclusion and .md via Parsedown
+--FILE--
+Hello world');
+echo trim(renderContentFile($htmlFile)) . "\n";
+
+// Markdown — converted to HTML by Parsedown
+$mdFile = "$dir/post.md";
+file_put_contents($mdFile, "# My Title\n\nA paragraph.");
+$html = renderContentFile($mdFile);
+echo (str_contains($html, 'My Title
') ? 'h1 ok' : 'h1 missing') . "\n";
+echo (str_contains($html, 'A paragraph.
') ? 'p ok' : 'p missing') . "\n";
+
+// Unknown extension — returns empty string
+$txtFile = "$dir/notes.txt";
+file_put_contents($txtFile, 'raw text');
+echo (renderContentFile($txtFile) === '') ? 'empty ok' : 'not empty';
+echo "\n";
+
+unlink($htmlFile);
+unlink($mdFile);
+unlink($txtFile);
+rmdir($dir);
+?>
+--EXPECT--
+Hello world
+h1 ok
+p ok
+empty ok
diff --git a/devel/tests/run.php b/devel/tests/run.php
new file mode 100644
index 0000000..f3fca6d
--- /dev/null
+++ b/devel/tests/run.php
@@ -0,0 +1,112 @@
+getExtension() === 'phpt') {
+ $files[] = $file->getPathname();
+ }
+}
+sort($files);
+
+$pass = 0;
+$fail = 0;
+$skip = 0;
+
+foreach ($files as $file) {
+ $rel = ltrim(substr($file, strlen(__DIR__), -5), '/');
+
+ if ($filter !== null && !str_contains($rel, $filter)) {
+ continue;
+ }
+
+ $sections = parse_phpt(file_get_contents($file));
+ $title = trim($sections['TEST'] ?? $rel);
+
+ if (!isset($sections['FILE']) || !isset($sections['EXPECT'])) {
+ echo RED . 'INVALID' . RESET . " $name (missing --FILE-- or --EXPECT--)\n";
+ $fail++;
+ continue;
+ }
+
+ if (isset($sections['SKIPIF'])) {
+ $result = trim(run_php($sections['SKIPIF']));
+ if (str_starts_with($result, 'skip')) {
+ $reason = ltrim(substr($result, 4));
+ echo YELLOW . 'SKIP' . RESET . " $title" . ($reason ? " — $reason" : '') . "\n";
+ $skip++;
+ continue;
+ }
+ }
+
+ $actual = trim(run_php($sections['FILE']));
+ $expected = trim($sections['EXPECT']);
+
+ if ($actual === $expected) {
+ echo GREEN . 'PASS' . RESET . " $title\n";
+ $pass++;
+ } else {
+ echo RED . 'FAIL' . RESET . " $title\n";
+ show_diff($expected, $actual);
+ $fail++;
+ }
+}
+
+$total = $pass + $fail + $skip;
+echo "\n" . BOLD . "Results: $pass/$total passed" . ($skip ? ", $skip skipped" : '') . RESET . "\n";
+exit($fail > 0 ? 1 : 0);
+
+// ---- helpers ----
+
+function parse_phpt(string $content): array
+{
+ $sections = [];
+ $current = null;
+ foreach (explode("\n", $content) as $line) {
+ if (preg_match('/^--([A-Z]+)--$/', rtrim($line), $m)) {
+ $current = $m[1];
+ $sections[$current] = '';
+ } elseif ($current !== null) {
+ $sections[$current] .= $line . "\n";
+ }
+ }
+ return $sections;
+}
+
+function run_php(string $code): string
+{
+ $tmp = tempnam('/tmp', 'phpt_');
+ file_put_contents($tmp, $code);
+ $output = shell_exec("php $tmp 2>&1");
+ unlink($tmp);
+ return $output ?? '';
+}
+
+function show_diff(string $expected, string $actual): void
+{
+ $exp = explode("\n", $expected);
+ $act = explode("\n", $actual);
+ $max = max(count($exp), count($act));
+ for ($i = 0; $i < $max; $i++) {
+ $e = $exp[$i] ?? null;
+ $a = $act[$i] ?? null;
+ if ($e === $a) {
+ echo " $e\n";
+ } else {
+ if ($e !== null) echo RED . " - $e" . RESET . "\n";
+ if ($a !== null) echo GREEN . " + $a" . RESET . "\n";
+ }
+ }
+}
diff --git a/devel/tests/run.sh b/devel/tests/run.sh
new file mode 100755
index 0000000..6fed8b1
--- /dev/null
+++ b/devel/tests/run.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+# Run phpt tests inside the container.
+# Usage: ./run.sh [filter] — filter matches against test filename substrings
+# Example: ./run.sh extract_raw_date
+
+CONTAINER=${CONTAINER:-folderweb-default}
+
+podman exec "$CONTAINER" php /var/www/tests/run.php "$@"
diff --git a/docs/05-testing/01-testing.md b/docs/05-testing/01-testing.md
new file mode 100644
index 0000000..c5ceab3
--- /dev/null
+++ b/docs/05-testing/01-testing.md
@@ -0,0 +1,73 @@
+# Testing
+
+## Workflow
+
+When modifying or adding functions in `app/`, follow this sequence — **a task is not complete until all steps are done**:
+
+1. **Write or update the code** in the relevant `app/*.php` file.
+2. **Write tests** in `devel/tests//` matching the changed file (e.g. `app/content.php` → `devel/tests/content/`).
+3. **Run the tests:** `cd devel && ./tests/run.sh`
+4. **Fix any failures** — do not consider the task done while tests are red.
+
+When tests already exist for the changed function, update them to cover the new behaviour.
+
+**Not unit-testable** (skip for these): `router.php`, `static.php`, template files, full HTTP flows.
+
+## Framework Tests (`app/`)
+
+Unit tests live in `devel/tests/` and use the [phpt format](https://php.github.io/php-src/miscellaneous/writing-tests.html) ([lessons learned](https://wiki.php.net/qa/phptlessonslearned)). The runner is a minimal PHP script — no test framework, no Composer. Tests cover `app/` only.
+
+### Running
+
+```bash
+cd devel
+
+./tests/run.sh # Run all tests
+./tests/run.sh helpers # Filter by subdir or filename substring
+```
+
+`CONTAINER=my-container ./tests/run.sh` to override the container name. Exit code `1` on any failure — CI-compatible. Container must be running (`podman-compose up -d`).
+
+### File Layout
+
+```
+devel/tests/
+ run.php # Runner — executed inside the container
+ run.sh # Host entry point (calls podman exec)
+ helpers/ # Tests for app/helpers.php
+ content/ # Tests for app/content.php
+ config/ # Tests for app/config.php
+ ... # One subdir per app/ module
+```
+
+### Writing Tests
+
+App files are at `/var/www/app/` inside the container. Require only what the function under test needs.
+
+```
+--TEST--
+extractRawDateFromFolder: extracts YYYY-MM-DD from date-prefixed folder names
+--FILE--
+
+--EXPECT--
+2024-01-15
+```
+
+Filesystem tests: use `sys_get_temp_dir() . '/phpt_' . getmypid()` and clean up in `--FILE--`.
+
+### What to Test
+
+| Test | How |
+|---|---|
+| Pure functions | Direct call + echo |
+| Filesystem functions | Temp dir, clean up after |
+| Config merging, metadata parsing | Temp files or inline strings |
+| Full HTTP flow | Browser or `perf.sh` — not unit-tested |
+| Template rendering | Not unit-tested |
+
+## Site Tests (`custom/`)
+
+For sites built on top of the framework, [Pest](https://github.com/pestphp/pest) is a good fit — it supports browser testing via Playwright, useful for end-to-end testing of custom templates, plugins, and content. Pest is not used in this repo.