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:
parent
33943a907b
commit
449e6f8e03
22 changed files with 909 additions and 7 deletions
16
AGENT.md
16
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/`.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
49
devel/tests/cache/build_cache_key.phpt
vendored
Normal file
49
devel/tests/cache/build_cache_key.phpt
vendored
Normal 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
|
||||
36
devel/tests/cache/get_set_cached_markdown.phpt
vendored
Normal file
36
devel/tests/cache/get_set_cached_markdown.phpt
vendored
Normal 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>
|
||||
47
devel/tests/content/find_all_content_files.phpt
Normal file
47
devel/tests/content/find_all_content_files.phpt
Normal 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
|
||||
44
devel/tests/content/load_metadata.phpt
Normal file
44
devel/tests/content/load_metadata.phpt
Normal 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
|
||||
48
devel/tests/content/resolve_slug_to_folder.phpt
Normal file
48
devel/tests/content/resolve_slug_to_folder.phpt
Normal 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
|
||||
54
devel/tests/context/context_data.phpt
Normal file
54
devel/tests/context/context_data.phpt
Normal 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
|
||||
45
devel/tests/helpers/extract_meta_description.phpt
Normal file
45
devel/tests/helpers/extract_meta_description.phpt
Normal 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.
|
||||
20
devel/tests/helpers/extract_raw_date_from_folder.phpt
Normal file
20
devel/tests/helpers/extract_raw_date_from_folder.phpt
Normal 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
|
||||
49
devel/tests/helpers/extract_title.phpt
Normal file
49
devel/tests/helpers/extract_title.phpt
Normal 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
|
||||
33
devel/tests/helpers/find_cover_image.phpt
Normal file
33
devel/tests/helpers/find_cover_image.phpt
Normal 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
|
||||
42
devel/tests/helpers/find_page_css.phpt
Normal file
42
devel/tests/helpers/find_page_css.phpt
Normal 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
|
||||
36
devel/tests/helpers/find_page_js.phpt
Normal file
36
devel/tests/helpers/find_page_js.phpt
Normal 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
|
||||
28
devel/tests/helpers/find_pdf_file.phpt
Normal file
28
devel/tests/helpers/find_pdf_file.phpt
Normal 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
|
||||
39
devel/tests/helpers/get_subdirectories.phpt
Normal file
39
devel/tests/helpers/get_subdirectories.phpt
Normal 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
|
||||
38
devel/tests/hooks/hooks_add_apply.phpt
Normal file
38
devel/tests/hooks/hooks_add_apply.phpt
Normal 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
|
||||
59
devel/tests/plugins/plugin_manager.phpt
Normal file
59
devel/tests/plugins/plugin_manager.phpt
Normal 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
|
||||
39
devel/tests/rendering/render_content_file.phpt
Normal file
39
devel/tests/rendering/render_content_file.phpt
Normal 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
112
devel/tests/run.php
Normal 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
8
devel/tests/run.sh
Executable 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 "$@"
|
||||
73
docs/05-testing/01-testing.md
Normal file
73
docs/05-testing/01-testing.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue