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.
|
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/`.
|
||||||
|
|
|
||||||
|
|
@ -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
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