Raiju/tests/agent-scan-mode-smoke.php
2026-10-25 06:05:42 +03:00

169 lines
9.5 KiB
PHP

<?php
declare(strict_types=1);
// RAIJU_CONTENT_SCAN / RAIJU_CONTENT_READONLY / content overlay: rendering must
// never write, and an overlay must replace stored content without touching disk.
// Each mode runs in its own PHP process, because the modes are constants.
//
// php tests/agent-scan-mode-smoke.php (runs every mode)
// php tests/agent-scan-mode-smoke.php worker <mode> <dir> (internal)
$root = dirname(__DIR__);
function scanAssert(bool $condition, string $message): void {
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
/** Every file below a directory with content hash, so new or touched files show up. */
function scanSnapshot(string $dir): array {
$files = [];
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->isFile()) {
$files[substr($file->getPathname(), strlen($dir))] = hash_file('sha256', $file->getPathname());
}
}
ksort($files);
return $files;
}
function scanFixturePage(): array {
return [
'page' => 'en_scan',
'lang' => 'en',
'title' => 'Scan fixture',
'hero' => ['title' => 'Stored title'],
'present' => ['title' => 'Stored present'],
'features' => ['items' => ['0' => ['title' => 'One'], '1' => ['title' => 'Two']]],
'sections' => [
['id' => 'intro', 'type' => 'text', 'content' => '<p>Stored section</p>'],
['id' => 'head', 'type' => 'heading', 'text' => 'Stored heading', 'level' => 2],
],
];
}
/** Everything a custom layout can call, with fields that are missing from the JSON. */
function scanRenderFixture(): string {
$page = 'en_scan';
$html = editableText($page, 'hero.title', 'Default title');
$html .= editableHtml($page, 'hero.body', '<p>Default body</p>');
$html .= editableLink($page, 'hero.cta', 'Go', '/go');
$html .= editableImage($page, 'hero.image', '/assets/images/placeholder-image.webp', 'Alt');
$html .= editableImageSplit($page, 'hero.src', 'hero.alt', '/assets/images/placeholder-image.webp', 'Alt');
$html .= editableIcon($page, 'hero.icon', 'star');
$html .= editableText($page, 'present.title', 'Unused default');
$html .= editableListAttrs($page, 'features.items', ['title' => 'New']);
foreach (editableListItems($page, 'features.items') as $index => $item) {
$html .= editableListItemAttrs($page, 'features.items', $index);
$html .= editableText($page, 'features.items.' . $index . '.text', 'Missing item text');
}
$html .= renderAllSections($page);
return $html;
}
if (($argv[1] ?? '') === 'worker') {
[$mode, $dir] = [$argv[2], $argv[3]];
ob_start(); // keep headers unsent so a session could start, if anything tried to
define('SITE_LANG_DEFAULT', 'en');
define('SITE_NAME', 'Scan smoke');
define('CONTENT_BASE_PATH', $dir . '/content/pages/');
define('RAIJU_MEDIA_META_PATH', $dir . '/content/media-meta.json');
if (in_array($mode, ['scan', 'scan-overlay'], true)) {
define('RAIJU_CONTENT_SCAN', true);
}
if ($mode === 'readonly') {
define('RAIJU_CONTENT_READONLY', true);
}
if (in_array($mode, ['readonly', 'overlay', 'control'], true)) {
// A real, logged-in editor session (the loopback development login).
session_save_path($dir . '/sessions');
session_id('scanmodesmoke0123456789abcdef01');
session_start();
$_SESSION = ['admin_logged_in' => true, 'admin_login_time' => time(), 'admin_user_id' => 'dev_admin',
'admin_role' => 'admin', 'raiju_dev_login' => true];
session_write_close();
$_COOKIE[session_name()] = 'scanmodesmoke0123456789abcdef01';
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['HTTP_HOST'] = 'localhost';
}
require_once $root . '/includes/content-loader.php';
$before = scanSnapshot($dir . '/content');
$overlayPage = scanFixturePage();
$overlayPage['hero']['title'] = 'Preview title';
$overlayPage['sections'][0]['content'] = '<p>Preview section</p>';
if ($mode === 'scan') {
scanAssert(session_status() === PHP_SESSION_NONE && isAdminLoggedIn(), 'scan mode: editor markup without a session');
$html = scanRenderFixture();
scanAssert(session_status() === PHP_SESSION_NONE, 'scan mode must not start a session');
scanAssert(str_contains($html, 'data-field="hero.title"') && str_contains($html, 'Stored title') && str_contains($html, 'Default body'), 'scan mode renders editable markup, stored values and defaults for missing fields');
scanAssert(str_contains($html, 'data-field="hero.cta"') && str_contains($html, 'data-field="hero.icon"'), 'scan mode renders link and icon fields');
scanAssert(autoGeneratedFields() === [], 'scan mode must not auto-generate');
scanAssert(scanSnapshot($dir . '/content') === $before, 'scan mode must leave the content byte-identical and create no files');
} elseif ($mode === 'scan-overlay') {
raijuContentOverlaySet('en_scan', $overlayPage);
$html = scanRenderFixture();
scanAssert(str_contains($html, 'Preview title') && str_contains($html, 'Preview section') && !str_contains($html, 'Stored title'), 'overlay content is rendered in scan mode');
scanAssert(scanSnapshot($dir . '/content') === $before && autoGeneratedFields() === [], 'scan with overlay writes nothing');
} elseif ($mode === 'readonly') {
scanAssert(raijuContentReadonly() && !raijuContentScanActive(), 'readonly flag without scan');
scanAssert(isAdminLoggedIn() && session_status() === PHP_SESSION_ACTIVE, 'readonly mode keeps the real session login');
$html = scanRenderFixture();
scanAssert(str_contains($html, 'Stored title') && str_contains($html, 'Default body'), 'readonly mode renders stored content plus defaults');
scanAssert(autoGeneratedFields() === [], 'readonly mode must not auto-generate');
scanAssert(scanSnapshot($dir . '/content') === $before, 'readonly mode must leave the content byte-identical');
} elseif ($mode === 'overlay') {
// Logged-in editor, no flags: only the overlay protects the file.
$plain = loadContent('en_scan');
scanAssert(($plain['hero']['title'] ?? '') === 'Stored title', 'loadContent without overlay reads the file');
$cached = loadContentCached('en_scan');
scanAssert($cached['hero']['title'] === 'Stored title', 'cache primed with stored content');
raijuContentOverlaySet('en_scan', $overlayPage);
scanAssert(loadContent('en_scan')['hero']['title'] === 'Preview title', 'loadContent prefers the overlay');
scanAssert(loadContentCached('en_scan')['hero']['title'] === 'Preview title', 'the cache must not serve stored content once an overlay is set');
$html = scanRenderFixture();
scanAssert(str_contains($html, 'Preview title') && str_contains($html, 'Preview section') && !str_contains($html, 'Stored title'), 'rendering uses the overlay');
$second = $overlayPage;
$second['hero']['title'] = 'Second preview';
raijuContentOverlaySet('en_scan', $second);
scanAssert(str_contains(scanRenderFixture(), 'Second preview'), 'a changed overlay replaces the previous one');
scanAssert(autoGeneratedFields() === [], 'an active overlay must not auto-generate');
scanAssert(scanSnapshot($dir . '/content') === $before, 'an overlay never writes: logged-in render left the content byte-identical');
scanAssert(loadContent('other_page') === ['sections' => []], 'other pages are unaffected by the overlay');
raijuContentOverlayClear();
scanAssert(loadContent('en_scan')['hero']['title'] === 'Stored title' && loadContentCached('en_scan')['hero']['title'] === 'Stored title', 'clearing restores the stored content');
} elseif ($mode === 'control') {
// Proves the harness can see writes: a normal editor render auto-generates the missing fields.
$html = scanRenderFixture();
scanAssert(autoGeneratedFields() !== [], 'control: editor render auto-generates missing fields');
scanAssert(scanSnapshot($dir . '/content') !== $before, 'control: the content changes without the flags');
$stored = json_decode((string)file_get_contents($dir . '/content/pages/en_scan.json'), true);
scanAssert(($stored['hero']['body'] ?? null) === '<p>Default body</p>', 'control: default written to JSON');
}
echo "ok {$mode}\n";
exit(0);
}
$modes = ['scan', 'scan-overlay', 'readonly', 'overlay', 'control'];
$root = dirname(__DIR__);
foreach ($modes as $mode) {
$dir = sys_get_temp_dir() . '/raiju-scan-smoke-' . bin2hex(random_bytes(4));
mkdir($dir . '/content/pages', 0755, true);
mkdir($dir . '/sessions', 0700, true);
file_put_contents($dir . '/content/pages/en_scan.json', json_encode(scanFixturePage(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
$process = proc_open([PHP_BINARY, __FILE__, 'worker', $mode, $dir], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
$code = proc_close($process);
exec('rm -rf ' . escapeshellarg($dir));
if ($code !== 0 || trim($stdout) !== 'ok ' . $mode) {
fwrite(STDERR, "FAIL mode {$mode}: exit {$code}\n{$stdout}{$stderr}\n");
exit(1);
}
}
echo "PASS agent scan mode: scan, scan+overlay, readonly and overlay renders leave content byte-identical; control mode writes\n";