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
This commit is contained in:
Ruben 2026-03-17 12:51:07 +01:00
parent 33943a907b
commit 449e6f8e03
22 changed files with 909 additions and 7 deletions

View file

@ -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. 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. 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. - **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 - **Security:** Path traversal protection, document root restriction, strict MIME types, escape all UGC
- **PHP:** Arrow functions, null coalescing, match expressions. Type hints where practical. Single-purpose functions, avoid side effects. Comments only for major sections.
## Code Style - **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.
- **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.
- **Templates:** `<?= htmlspecialchars($var) ?>` for UGC. `<?= $content ?>` for pre-rendered HTML. - **Templates:** `<?= htmlspecialchars($var) ?>` for UGC. `<?= $content ?>` for pre-rendered HTML.
## Knowledge Base ## 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 | | 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 | | 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 | | 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/`. Human-facing docs (tutorials, reference) are in `docs/01-getting-started/`, `docs/02-tutorial/`, `docs/03-reference/`.

View file

@ -9,6 +9,7 @@ services:
- ../app:/var/www/app:z - ../app:/var/www/app:z
- ./apache/custom.conf:/etc/apache2/conf-available/custom.conf:z - ./apache/custom.conf:/etc/apache2/conf-available/custom.conf:z
- ./apache/default.conf:/etc/apache2/sites-available/000-default.conf:z - ./apache/default.conf:/etc/apache2/sites-available/000-default.conf:z
- ./tests:/var/www/tests:z
ports: ports:
- "8080:80" - "8080:80"
command: bash -c "a2enconf custom && a2enmod rewrite && apache2-foreground" command: bash -c "a2enconf custom && a2enmod rewrite && apache2-foreground"

49
devel/tests/cache/build_cache_key.phpt vendored Normal file
View file

@ -0,0 +1,49 @@
--TEST--
buildCacheKey: returns md5 string, varies by path and langPrefix
--FILE--
<?php
require '/var/www/app/cache.php';
$dir = sys_get_temp_dir() . '/phpt_cachekey_' . getmypid();
mkdir($dir);
$file = "$dir/page.md";
file_put_contents($file, '# Hello');
// Returns a 32-char hex string
$key = buildCacheKey($file);
echo (bool)preg_match('/^[0-9a-f]{32}$/', $key) ? 'valid md5' : 'invalid';
echo "\n";
// Same inputs produce same key
echo ($key === buildCacheKey($file)) ? 'stable' : 'unstable';
echo "\n";
// Different langPrefix produces different key
$keyFr = buildCacheKey($file, '/fr');
echo ($key !== $keyFr) ? 'prefix differs' : 'prefix same';
echo "\n";
// Different file path produces different key
$file2 = "$dir/other.md";
file_put_contents($file2, '# Other');
$key2 = buildCacheKey($file2);
echo ($key !== $key2) ? 'path differs' : 'path same';
echo "\n";
// Adding metadata.ini changes the key
file_put_contents("$dir/metadata.ini", "title = Test\n");
$keyWithMeta = buildCacheKey($file);
echo ($key !== $keyWithMeta) ? 'meta invalidates' : 'meta ignored';
echo "\n";
unlink($file);
unlink($file2);
unlink("$dir/metadata.ini");
rmdir($dir);
?>
--EXPECT--
valid md5
stable
prefix differs
path differs
meta invalidates

View file

@ -0,0 +1,36 @@
--TEST--
getCachedMarkdown / setCachedMarkdown: store and retrieve rendered HTML by file key
--FILE--
<?php
require '/var/www/app/cache.php';
$dir = sys_get_temp_dir() . '/phpt_mdcache_' . getmypid();
mkdir($dir);
$file = "$dir/post.md";
file_put_contents($file, '# Hello');
// Nothing cached yet
echo (getCachedMarkdown($file) ?? 'null') . "\n";
// Store and retrieve
setCachedMarkdown($file, '<h1>Hello</h1>');
echo getCachedMarkdown($file) . "\n";
// langPrefix produces a separate cache entry
echo (getCachedMarkdown($file, '/fr') ?? 'null') . "\n";
setCachedMarkdown($file, '<h1>Bonjour</h1>', '/fr');
echo getCachedMarkdown($file, '/fr') . "\n";
// Default key is unaffected
echo getCachedMarkdown($file) . "\n";
unlink($file);
rmdir($dir);
?>
--EXPECT--
null
<h1>Hello</h1>
null
<h1>Bonjour</h1>
<h1>Hello</h1>

View file

@ -0,0 +1,47 @@
--TEST--
findAllContentFiles: returns sorted content file paths, ignores non-content files and index.php
--FILE--
<?php
require '/var/www/app/constants.php';
require '/var/www/app/hooks.php';
require '/var/www/app/content.php';
$dir = sys_get_temp_dir() . '/phpt_content_' . getmypid();
mkdir($dir);
// Non-existent directory
echo count(findAllContentFiles($dir . '/nope')) . "\n";
// Empty directory
echo count(findAllContentFiles($dir)) . "\n";
// Content files
file_put_contents("$dir/page.md", '# Hello');
file_put_contents("$dir/extra.html", '<p>Hi</p>');
file_put_contents("$dir/script.php", '<?php echo "hi";');
// Non-content files are excluded
file_put_contents("$dir/styles.css", 'body {}');
file_put_contents("$dir/image.jpg", '');
// index.php is always excluded
file_put_contents("$dir/index.php", '<?php');
$files = findAllContentFiles($dir);
echo count($files) . "\n";
// Results are full paths, sorted by filename (natural order)
foreach ($files as $f) {
echo basename($f) . "\n";
}
array_map('unlink', glob("$dir/*"));
rmdir($dir);
?>
--EXPECT--
0
0
3
extra.html
page.md
script.php

View file

@ -0,0 +1,44 @@
--TEST--
loadMetadata: parses metadata.ini, returns null when absent
--FILE--
<?php
require '/var/www/app/hooks.php';
require '/var/www/app/content.php';
$dir = sys_get_temp_dir() . '/phpt_meta_' . getmypid();
mkdir($dir);
// No metadata file
echo (loadMetadata($dir) ?? 'null') . "\n";
// Basic key-value metadata
file_put_contents("$dir/metadata.ini", "title = My Page\nsummary = A summary\nmenu = true\n");
$meta = loadMetadata($dir);
echo $meta['title'] . "\n";
echo $meta['summary'] . "\n";
echo $meta['menu'] . "\n";
// _raw is always present
echo isset($meta['_raw']) ? 'raw present' : 'raw missing';
echo "\n";
// Sections are in _raw but not in base metadata
file_put_contents("$dir/metadata.ini", "[en]\ntitle = English Title\n\n[fr]\ntitle = French Title\n");
$meta = loadMetadata($dir);
echo isset($meta['en']) ? 'section leaked' : 'section contained';
echo "\n";
echo $meta['_raw']['en']['title'] . "\n";
echo $meta['_raw']['fr']['title'] . "\n";
array_map('unlink', glob("$dir/*"));
rmdir($dir);
?>
--EXPECT--
null
My Page
A summary
1
raw present
section contained
English Title
French Title

View file

@ -0,0 +1,48 @@
--TEST--
resolveSlugToFolder: resolves URL slug to actual folder name, supports metadata slug override
--FILE--
<?php
require '/var/www/app/hooks.php';
require '/var/www/app/content.php';
$parent = sys_get_temp_dir() . '/phpt_slug_' . getmypid();
mkdir($parent);
// Non-existent parent directory
echo (resolveSlugToFolder($parent . '/nope', 'anything') ?? 'null') . "\n";
// Folder name matches slug directly
mkdir("$parent/my-post");
echo resolveSlugToFolder($parent, 'my-post') . "\n";
// Slug not found
echo (resolveSlugToFolder($parent, 'missing') ?? 'null') . "\n";
// Metadata slug overrides folder name
mkdir("$parent/2024-01-15-article");
file_put_contents("$parent/2024-01-15-article/metadata.ini", "slug = article\n");
echo resolveSlugToFolder($parent, 'article') . "\n";
// Original folder name still works when no slug conflicts
echo resolveSlugToFolder($parent, '2024-01-15-article') . "\n";
// Metadata slug takes precedence over folder with that name
mkdir("$parent/other");
file_put_contents("$parent/other/metadata.ini", "slug = my-post\n");
// 'my-post' folder exists AND 'other' has slug=my-post — folder name wins (first match)
echo resolveSlugToFolder($parent, 'my-post') . "\n";
array_map('unlink', glob("$parent/2024-01-15-article/*"));
rmdir("$parent/2024-01-15-article");
array_map('unlink', glob("$parent/other/*"));
rmdir("$parent/other");
rmdir("$parent/my-post");
rmdir($parent);
?>
--EXPECT--
null
my-post
null
2024-01-15-article
2024-01-15-article
my-post

View file

@ -0,0 +1,54 @@
--TEST--
Context: set/get/has and magic property access for plugin data storage
--FILE--
<?php
require '/var/www/app/context.php';
$templates = new Templates(base: '/tmp/b.php', page: '/tmp/p.php', list: '/tmp/l.php');
$ctx = new Context(
contentDir: '/tmp/content',
templates: $templates,
requestPath: 'blog/post',
hasTrailingSlash: true
);
// Public readonly properties
echo $ctx->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

View file

@ -0,0 +1,45 @@
--TEST--
extractMetaDescription: returns description from metadata fields or first content paragraph
--FILE--
<?php
require '/var/www/app/constants.php';
require '/var/www/app/hooks.php';
require '/var/www/app/content.php';
require '/var/www/app/helpers.php';
$dir = sys_get_temp_dir() . '/phpt_metadesc_' . getmypid();
mkdir($dir);
// search_description takes priority
echo extractMetaDescription($dir, ['search_description' => '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 <p> from HTML
file_put_contents("$dir/index.html", "<h1>Title</h1><p>HTML paragraph text here.</p>");
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.

View file

@ -0,0 +1,20 @@
--TEST--
extractRawDateFromFolder: extracts YYYY-MM-DD from date-prefixed folder names
--FILE--
<?php
require '/var/www/app/helpers.php';
echo extractRawDateFromFolder('2024-01-15-my-post') . "\n";
echo extractRawDateFromFolder('2024-12-31-year-end') . "\n";
echo (extractRawDateFromFolder('no-date-here') ?? 'null') . "\n";
echo (extractRawDateFromFolder('') ?? 'null') . "\n";
echo (extractRawDateFromFolder('2024-1-1-bad-format') ?? 'null') . "\n";
echo (extractRawDateFromFolder('20240115-no-separators') ?? 'null') . "\n";
?>
--EXPECT--
2024-01-15
2024-12-31
null
null
null
null

View file

@ -0,0 +1,49 @@
--TEST--
extractTitle: extracts title from # heading in .md or <h1> in .html
--FILE--
<?php
require '/var/www/app/constants.php';
require '/var/www/app/hooks.php';
require '/var/www/app/content.php';
require '/var/www/app/helpers.php';
$dir = sys_get_temp_dir() . '/phpt_title_' . getmypid();
mkdir($dir);
// No content files
echo (extractTitle($dir) ?? 'null') . "\n";
// Markdown with # heading
file_put_contents("$dir/index.md", "# My Great Post\n\nSome body text.");
echo extractTitle($dir) . "\n";
unlink("$dir/index.md");
// Markdown heading with extra whitespace
file_put_contents("$dir/index.md", "# Spaced Title \n\nBody.");
echo extractTitle($dir) . "\n";
unlink("$dir/index.md");
// HTML with <h1>
file_put_contents("$dir/index.html", "<h1>HTML Title</h1><p>Body</p>");
echo extractTitle($dir) . "\n";
unlink("$dir/index.html");
// HTML with attributes on h1
file_put_contents("$dir/index.html", '<h1 class="main">Styled Title</h1>');
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

View file

@ -0,0 +1,33 @@
--TEST--
findCoverImage: finds cover.{ext} in directory, prefers first matching extension
--FILE--
<?php
require '/var/www/app/constants.php';
require '/var/www/app/helpers.php';
$dir = sys_get_temp_dir() . '/phpt_cover_' . getmypid();
mkdir($dir);
// No cover image
echo (findCoverImage($dir) ?? 'null') . "\n";
// cover.jpg
touch("$dir/cover.jpg");
echo findCoverImage($dir) . "\n";
// cover.webp added — jpg should still win (it's first in COVER_IMAGE_EXTENSIONS)
touch("$dir/cover.webp");
echo findCoverImage($dir) . "\n";
// Remove jpg — webp should now be found
unlink("$dir/cover.jpg");
echo findCoverImage($dir) . "\n";
array_map('unlink', glob("$dir/*"));
rmdir($dir);
?>
--EXPECT--
null
cover.jpg
cover.jpg
cover.webp

View file

@ -0,0 +1,42 @@
--TEST--
findPageCss: returns URL and hash for styles.css, null when absent
--FILE--
<?php
require '/var/www/app/helpers.php';
$contentDir = sys_get_temp_dir() . '/phpt_css_' . getmypid();
$pageDir = "$contentDir/blog/post";
mkdir($pageDir, 0777, true);
// No styles.css
echo (findPageCss($pageDir, $contentDir) ?? 'null') . "\n";
// Add styles.css
file_put_contents("$pageDir/styles.css", 'body { color: red; }');
$result = findPageCss($pageDir, $contentDir);
echo $result['url'] . "\n";
echo ($result['hash'] === md5_file("$pageDir/styles.css") ? 'hash ok' : 'hash mismatch') . "\n";
// Root-level page (no subpath)
$rootCss = "$contentDir/styles.css";
file_put_contents($rootCss, 'body {}');
$result = findPageCss($contentDir, $contentDir);
echo $result['url'] . "\n";
// Directory named styles.css is ignored
unlink("$pageDir/styles.css");
mkdir("$pageDir/styles.css");
echo (findPageCss($pageDir, $contentDir) ?? 'null') . "\n";
rmdir("$pageDir/styles.css");
unlink($rootCss);
rmdir($pageDir);
rmdir("$contentDir/blog");
rmdir($contentDir);
?>
--EXPECT--
null
/blog/post/styles.css
hash ok
/styles.css
null

View file

@ -0,0 +1,36 @@
--TEST--
findPageJs: returns URL and hash for script.js, null when absent
--FILE--
<?php
require '/var/www/app/helpers.php';
$contentDir = sys_get_temp_dir() . '/phpt_js_' . getmypid();
$pageDir = "$contentDir/section/page";
mkdir($pageDir, 0777, true);
// No script.js
echo (findPageJs($pageDir, $contentDir) ?? 'null') . "\n";
// Add script.js
file_put_contents("$pageDir/script.js", 'console.log("hi");');
$result = findPageJs($pageDir, $contentDir);
echo $result['url'] . "\n";
echo ($result['hash'] === md5_file("$pageDir/script.js") ? 'hash ok' : 'hash mismatch') . "\n";
// Root-level page (no subpath)
$rootJs = "$contentDir/script.js";
file_put_contents($rootJs, '');
$result = findPageJs($contentDir, $contentDir);
echo $result['url'] . "\n";
unlink("$pageDir/script.js");
unlink($rootJs);
rmdir($pageDir);
rmdir("$contentDir/section");
rmdir($contentDir);
?>
--EXPECT--
null
/section/page/script.js
hash ok
/script.js

View file

@ -0,0 +1,28 @@
--TEST--
findPdfFile: finds first .pdf file in a directory
--FILE--
<?php
require '/var/www/app/helpers.php';
$dir = sys_get_temp_dir() . '/phpt_pdf_' . getmypid();
mkdir($dir);
// No PDF
echo (findPdfFile($dir) ?? 'null') . "\n";
// One PDF
touch("$dir/document.pdf");
echo findPdfFile($dir) . "\n";
// Non-PDF files are ignored
touch("$dir/image.jpg");
touch("$dir/notes.txt");
echo findPdfFile($dir) . "\n";
array_map('unlink', glob("$dir/*"));
rmdir($dir);
?>
--EXPECT--
null
document.pdf
document.pdf

View file

@ -0,0 +1,39 @@
--TEST--
getSubdirectories: returns only subdirectory names, ignores files
--FILE--
<?php
require '/var/www/app/helpers.php';
$dir = sys_get_temp_dir() . '/phpt_subdirs_' . getmypid();
mkdir($dir);
// Empty directory
$result = getSubdirectories($dir);
echo (empty($result) ? 'empty' : 'not empty') . "\n";
// Non-existent directory
echo count(getSubdirectories($dir . '/nope')) . "\n";
// Add subdirectories and a file
mkdir("$dir/alpha");
mkdir("$dir/beta");
touch("$dir/file.txt");
$result = array_values(getSubdirectories($dir));
sort($result);
echo implode("\n", $result) . "\n";
// Files are not included
echo in_array('file.txt', $result) ? "file included\n" : "file excluded\n";
unlink("$dir/file.txt");
rmdir("$dir/alpha");
rmdir("$dir/beta");
rmdir($dir);
?>
--EXPECT--
empty
0
alpha
beta
file excluded

View file

@ -0,0 +1,38 @@
--TEST--
Hooks: apply passes value through, add registers filters that chain in order
--FILE--
<?php
require '/var/www/app/hooks.php';
// No filters registered — passthrough
echo Hooks::apply(Hook::PROCESS_CONTENT, 'hello') . "\n";
echo Hooks::apply(Hook::TEMPLATE_VARS, 'foo') . "\n";
// Register a filter
Hooks::add(Hook::PROCESS_CONTENT, fn($v) => 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

View file

@ -0,0 +1,59 @@
--TEST--
PluginManager: tracks loaded plugins, deduplicate loads, respects scope
--FILE--
<?php
require '/var/www/app/hooks.php';
require '/var/www/app/context.php';
require '/var/www/app/plugins.php';
$pm = new PluginManager();
// Initially empty
echo count($pm->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

View file

@ -0,0 +1,39 @@
--TEST--
renderContentFile: renders .html by inclusion and .md via Parsedown
--FILE--
<?php
require '/var/www/app/context.php';
require '/var/www/app/hooks.php';
require '/var/www/app/rendering.php';
$dir = sys_get_temp_dir() . '/phpt_render_' . getmypid();
mkdir($dir);
// HTML file — returned as-is (direct inclusion)
$htmlFile = "$dir/page.html";
file_put_contents($htmlFile, '<p>Hello <strong>world</strong></p>');
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, '<h1>My Title</h1>') ? 'h1 ok' : 'h1 missing') . "\n";
echo (str_contains($html, '<p>A paragraph.</p>') ? '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--
<p>Hello <strong>world</strong></p>
h1 ok
p ok
empty ok

112
devel/tests/run.php Normal file
View file

@ -0,0 +1,112 @@
<?php
// phpt test runner — execute via run.sh, runs inside the container
const GREEN = "\033[32m";
const RED = "\033[31m";
const YELLOW = "\033[33m";
const RESET = "\033[0m";
const BOLD = "\033[1m";
$filter = $argv[1] ?? null;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(__DIR__, FilesystemIterator::SKIP_DOTS)
);
$files = [];
foreach ($iterator as $file) {
if ($file->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";
}
}
}

8
devel/tests/run.sh Executable file
View file

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

View file

@ -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/<module>/` 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--
<?php
require '/var/www/app/helpers.php';
echo extractRawDateFromFolder('2024-01-15-my-post');
?>
--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.