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

538 lines
56 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
// Change sets: staging, validation, render check, review data, apply with journal and
// rollback, undo, publish, conflicts, crash recovery. Runs against a throw-away site built
// from tests/fixtures/agent/site; custom templates and crashes run in their own processes.
//
// php tests/agent-changeset-smoke.php
// php tests/agent-changeset-smoke.php worker <scenario> <site> [args] (internal)
function csAssert(bool $condition, string $message): void {
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
// ---------------------------------------------------------------------------
// Worker: one scenario per process
// ---------------------------------------------------------------------------
if (($argv[1] ?? '') === 'worker') {
[$scenario, $site] = [$argv[2], $argv[3]];
$args = array_slice($argv, 4);
define('SITE_LANG_DEFAULT', 'en');
define('CONTENT_BASE_PATH', $site . '/content/pages/');
require_once $site . '/includes/ai/agent/changeset-apply.php';
$out = static function (array $data): void {
echo "RESULT:" . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
};
$stage = static fn(string $id, array $request): array => raijuAgentChangesetStage($id, $request);
if ($scenario === 'custom-stage') {
$cs = raijuAgentChangesetCreate(['owner' => 'u_editor', 'runId' => 'run_custom', 'allowedUrls' => ['https://example.org/allowed']]);
$id = $cs['id'];
$t = 'page:en_scan-custom';
$r = [];
$r['title'] = $stage($id, ['type' => 'set_field', 'target' => $t, 'path' => 'hero.title', 'value' => 'New headline']);
$r['titleTooLong'] = $stage($id, ['type' => 'set_field', 'target' => $t, 'path' => 'hero.title', 'value' => str_repeat('x', 100)]);
$r['linkOk'] = $stage($id, ['type' => 'set_field', 'target' => $t, 'path' => 'hero.more', 'value' => ['text' => 'Read more', 'href' => 'https://example.org/allowed']]);
$r['linkExternal'] = $stage($id, ['type' => 'set_field', 'target' => $t, 'path' => 'hero.more', 'value' => ['text' => 'Spam', 'href' => 'https://spam.example/buy']]);
$r['linkJs'] = $stage($id, ['type' => 'set_field', 'target' => $t, 'path' => 'hero.more', 'value' => ['text' => 'x', 'href' => 'javascript:alert(1)']]);
$r['linkInternal'] = $stage($id, ['type' => 'set_field', 'target' => $t, 'path' => 'hero.more', 'value' => ['text' => 'Contact', 'href' => '/contact']]);
$r['readOnly'] = $stage($id, ['type' => 'set_field', 'target' => $t, 'path' => 'hero.cta', 'value' => ['text' => 'x', 'href' => '/x']]);
$r['structural'] = $stage($id, ['type' => 'set_field', 'target' => $t, 'path' => 'hero.type', 'value' => 'x']);
$r['sensitive'] = $stage($id, ['type' => 'set_field', 'target' => $t, 'path' => 'contact.apiKey', 'value' => 'x']);
$r['imageObject'] = $stage($id, ['type' => 'set_image', 'target' => $t, 'path' => 'hero.image', 'libraryPath' => '/assets/images/second.webp', 'alt' => 'Second image']);
$r['imageSplit'] = $stage($id, ['type' => 'set_image', 'target' => $t, 'path' => 'hero.photo', 'libraryPath' => '/assets/images/second.webp', 'alt' => 'Split alt']);
$r['imageString'] = $stage($id, ['type' => 'set_image', 'target' => $t, 'path' => 'gallery.cover', 'libraryPath' => '/assets/images/second.webp']);
$r['imageNoAlt'] = $stage($id, ['type' => 'set_image', 'target' => $t, 'path' => 'hero.image', 'libraryPath' => '/assets/images/second.webp']);
// lists: the index always refers to the state after the previous operations
$r['insert'] = $stage($id, ['type' => 'list_insert', 'target' => $t, 'listKey' => 'features.items', 'position' => 1,
'item' => ['title' => 'Inserted', 'text' => 'Fresh text', 'icon' => 'bolt', 'image' => ['libraryPath' => '/assets/images/second.webp', 'alt' => 'Item image']]]);
$r['insertUnknown'] = $stage($id, ['type' => 'list_insert', 'target' => $t, 'listKey' => 'features.items', 'item' => ['colour' => 'red']]);
$r['itemTitle'] = $stage($id, ['type' => 'set_field', 'target' => $t, 'path' => 'features.items.1.title', 'value' => 'Inserted (edited)']);
$r['itemOutOfRange'] = $stage($id, ['type' => 'set_field', 'target' => $t, 'path' => 'features.items.9.title', 'value' => 'x']);
$r['itemUnknownField'] = $stage($id, ['type' => 'set_field', 'target' => $t, 'path' => 'features.items.1.colour', 'value' => 'x']);
$r['hide'] = $stage($id, ['type' => 'list_hide', 'target' => $t, 'listKey' => 'features.items', 'index' => 0, 'hidden' => true]);
$r['hideRange'] = $stage($id, ['type' => 'list_hide', 'target' => $t, 'listKey' => 'features.items', 'index' => 7, 'hidden' => true]);
$r['removeWrong'] = $stage($id, ['type' => 'list_remove', 'target' => $t, 'listKey' => 'features.items', 'index' => 3, 'titlePrefix' => 'Nope']);
$r['move'] = $stage($id, ['type' => 'list_move', 'target' => $t, 'listKey' => 'features.items', 'from' => 3, 'to' => 0]);
$r['moveRange'] = $stage($id, ['type' => 'list_move', 'target' => $t, 'listKey' => 'features.items', 'from' => 0, 'to' => 9]);
$r['notAList'] = $stage($id, ['type' => 'list_insert', 'target' => $t, 'listKey' => 'hero', 'item' => ['title' => 'x']]);
$r['faq'] = $stage($id, ['type' => 'list_insert', 'target' => $t, 'listKey' => 'faq.items', 'item' => ['question' => 'Why?', 'answer' => 'Because.', 'link' => ['text' => 'More', 'href' => 'https://example.org/allowed']]]);
$r['faqLink'] = $stage($id, ['type' => 'list_insert', 'target' => $t, 'listKey' => 'faq.items', 'item' => ['question' => 'Spam?', 'link' => ['text' => 'x', 'href' => 'https://spam.example']]]);
$r['sectionOnTemplate'] = $stage($id, ['type' => 'section_insert', 'target' => $t, 'position' => 0, 'sectionType' => 'text', 'fields' => []]);
$cs = raijuAgentChangesetLoad($id);
$docs = raijuAgentReplayOps($cs['ops']);
$items = raijuAgentFinalDoc($docs['state'], $t)['features']['items'];
// This process already rendered the template for the capability scan, so the render check has to wait.
$out(['id' => $id, 'results' => $r, 'items' => array_map(static fn($i) => [$i['title'], !empty($i['hidden'])], $items), 'isList' => array_is_list($items),
'opCount' => count($cs['ops']), 'sameProcessFinalize' => raijuAgentChangesetFinalize($id)]);
} elseif ($scenario === 'finalize') {
$out(['finalize' => raijuAgentChangesetFinalize($args[0])]);
} elseif ($scenario === 'custom-limits') {
$cs = raijuAgentChangesetCreate(['owner' => 'u_editor', 'runId' => 'run_limits']);
$id = $cs['id'];
$t = 'page:en_scan-custom';
$over = null;
for ($i = 0; $i < 6; $i++) {
$res = $stage($id, ['type' => 'list_insert', 'target' => $t, 'listKey' => 'features.items', 'item' => ['title' => 'Item ' . $i]]);
if (isset($res['error'])) { $over = [$i, $res['error']]; break; }
}
// removing below the site minimum is only reported when the package is presented
$c2 = raijuAgentChangesetCreate(['owner' => 'u_editor', 'runId' => 'run_min']);
foreach ([2 => 'Three', 1 => 'Two'] as $index => $prefix) {
$stage($c2['id'], ['type' => 'list_remove', 'target' => $t, 'listKey' => 'features.items', 'index' => $index, 'titlePrefix' => $prefix]);
}
$out(['over' => $over, 'removeOps' => count(raijuAgentChangesetLoad($c2['id'])['ops']), 'minId' => $c2['id'], 'minFinalize' => raijuAgentChangesetFinalize($c2['id'])]);
} elseif ($scenario === 'apply') {
$cs = raijuAgentChangesetLoad($args[0]);
$out(['apply' => raijuAgentChangesetApply($args[0], $cs['previewHash'], ['userKey' => 'u_editor', 'isAdmin' => false])]);
} elseif ($scenario === 'crash') {
$cs = raijuAgentChangesetLoad($args[0]);
$writes = 0;
raijuAgentChangesetApply($args[0], $cs['previewHash'], ['userKey' => 'u_editor', 'isAdmin' => false], ['testHook' => static function (string $event, string $file) use (&$writes): void {
if (++$writes === 1) exit(9); // the process dies after the first file was written
}]);
echo "NOT-REACHED\n";
} elseif ($scenario === 'manual-op') {
// An operation naming a path the template never renders: the render check must catch it.
$cs = raijuAgentChangesetCreate(['owner' => 'u_editor', 'runId' => 'run_manual']);
raijuAgentChangesetMutate($cs['id'], static function (array &$cs) {
$cs['ops'][] = ['id' => 'op_1', 'type' => 'set_field', 'target' => 'page:en_scan-custom', 'path' => 'hero.video', 'spec' => ['type' => 'text', 'max' => 100, 'req' => false, 'options' => [], 'fmt' => null, 'altPath' => null], 'value' => 'abc'];
$cs['base']['page:en_scan-custom'] = raijuJsonRevision(raijuAgentSiteRoot() . '/content/pages/en_scan-custom.json');
return true;
});
$out(['id' => $cs['id'], 'finalize' => raijuAgentChangesetFinalize($cs['id'])]);
}
exit(0);
}
// ---------------------------------------------------------------------------
// Orchestrator
// ---------------------------------------------------------------------------
$root = dirname(__DIR__);
$site = sys_get_temp_dir() . '/raiju-changeset-' . bin2hex(random_bytes(4));
mkdir($site . '/tests', 0755, true);
register_shutdown_function(static fn() => exec('rm -rf ' . escapeshellarg($site)));
foreach (['includes', 'admin', 'VERSION'] as $item) exec('cp -R ' . escapeshellarg($root . '/' . $item) . ' ' . escapeshellarg($site . '/'));
foreach (['admin/config.php', 'admin/smtp-config.php', 'includes/nav-config.php'] as $local) @unlink($site . '/' . $local);
exec('cp -R ' . escapeshellarg($root . '/tests/fixtures/agent/site/.') . ' ' . escapeshellarg($site . '/'));
rename($site . '/lang-en', $site . '/en');
copy(__FILE__, $site . '/tests/agent-changeset-smoke.php');
mkdir($site . '/assets/images', 0755, true);
mkdir($site . '/content/news', 0755, true);
copy($root . '/assets/images/placeholder-image.webp', $site . '/assets/images/library.webp');
copy($root . '/assets/images/placeholder-image.webp', $site . '/assets/images/second.webp');
file_put_contents($site . '/content/news/2026-10-01-demo-post.json', json_encode(['id' => '2026-10-01-demo-post', 'slug' => 'demo-post', 'lang' => 'en', 'title' => 'Demo post', 'date' => '2026-10-01', 'author' => 'Team', 'excerpt' => 'Short', 'image' => '/assets/images/library.webp', 'content' => '<p>Body</p>', 'hidden' => false]));
file_put_contents($site . '/content/events.json', json_encode(['events' => [[
'id' => '2026-11-05-open-day', 'date' => '2026-11-05', 'time' => '10:00', 'end-date' => '2026-11-05', 'end-time' => '16:00', 'url' => '',
'title' => ['en' => 'Open day', 'de' => 'Tag der offenen Tür'], 'location' => ['en' => 'Hall'], 'description' => ['en' => '<p>Come</p>'], 'admission' => ['en' => 'Free'], 'image' => '',
]]]));
file_put_contents($site . '/content/pages/footer.json', json_encode(['tagline' => ['en' => 'We build'], 'copyright' => '&copy; Fixture']));
define('SITE_LANG_DEFAULT', 'en');
define('CONTENT_BASE_PATH', $site . '/content/pages/');
require_once $site . '/includes/ai/agent/changeset-apply.php';
$owner = ['userKey' => 'u_editor', 'isAdmin' => false, 'canPublish' => false];
$publisher = ['userKey' => 'u_editor', 'isAdmin' => false, 'canPublish' => true];
$stranger = ['userKey' => 'u_other', 'isAdmin' => false, 'canPublish' => true];
$admin = ['userKey' => 'u_admin', 'isAdmin' => true, 'canPublish' => true];
$allowed = ['https://example.org/source', 'mailto:team@example.org'];
function csContent(string $site): array {
$files = array_merge(glob($site . '/content/pages/*.json') ?: [], glob($site . '/content/news/*.json') ?: [], [$site . '/content/events.json']);
$hashes = [];
foreach ($files as $file) $hashes[basename($file)] = is_file($file) ? hash_file('sha256', $file) : null;
ksort($hashes);
return $hashes;
}
function csNew(array $meta = []): string {
return raijuAgentChangesetCreate($meta + ['owner' => 'u_editor', 'runId' => 'run_1', 'allowedUrls' => ['https://example.org/source', 'mailto:team@example.org']])['id'];
}
function csStage(string $id, array $request): array {
return raijuAgentChangesetStage($id, $request);
}
function csOk(string $id, array $request, string $what): array {
$result = csStage($id, $request);
csAssert(!empty($result['ok']), "{$what} must be accepted: " . json_encode($result));
return $result;
}
function csRejected(string $id, array $request, string $needle, string $what): array {
$before = count(raijuAgentChangesetLoad($id)['ops']);
$result = csStage($id, $request);
csAssert(isset($result['error']) && stripos($result['error'], $needle) !== false, "{$what} must be rejected mentioning '{$needle}': " . json_encode($result));
csAssert(count(raijuAgentChangesetLoad($id)['ops']) === $before, "{$what}: a rejected operation must not be stored");
return $result;
}
function csDoc(string $site, string $relative): array {
return json_decode((string)file_get_contents($site . '/' . $relative), true);
}
function csWorker(string $site, string $scenario, string ...$args): array {
$process = proc_open([PHP_BINARY, '-d', 'display_errors=0', $site . '/tests/agent-changeset-smoke.php', 'worker', $scenario, $site, ...$args], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $site);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
$code = proc_close($process);
foreach (explode("\n", $stdout) as $line) {
if (str_starts_with($line, 'RESULT:')) return json_decode(substr($line, 7), true, 512, JSON_THROW_ON_ERROR) + ['_code' => $code];
}
return ['_stdout' => $stdout, '_stderr' => $stderr, '_code' => $code];
}
$pristine = csContent($site);
$std = 'page:en_scan-standard';
// ---- Staging: everything the agent has to correct is refused, nothing half-stored -------
$a = csNew();
csRejected($a, ['type' => 'set_field', 'target' => 'page:../x', 'path' => 'title', 'value' => 'x'], 'unknown target', 'path traversal in the target');
csRejected($a, ['type' => 'set_field', 'target' => 'page:en_missing', 'path' => 'title', 'value' => 'x'], 'unknown target', 'missing page');
csRejected($a, ['type' => 'explode', 'target' => $std], 'unknown operation', 'unknown operation');
csRejected($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.1.title', 'value' => 'x', 'file' => '/etc/passwd'], "'file' is not an argument", 'extra argument');
csRejected($a, ['type' => 'set_field', 'target' => $std, 'value' => 'x'], "'path' is required", 'missing path');
$err = csRejected($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.0.level', 'value' => 'h4'], 'not an editable field', 'structural field');
csAssert(str_contains($err['hint'], 'text, subtitle'), 'the hint names the editable fields: ' . $err['hint']);
csRejected($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.9.title', 'value' => 'x'], 'the page has 4 sections', 'section out of range');
csRejected($a, ['type' => 'set_field', 'target' => $std, 'path' => 'title', 'value' => 'x'], 'not an editable field', 'a path that is not rendered');
csRejected($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.2.src', 'value' => '/x.png'], 'is an image', 'image path with set_field');
csRejected($a, ['type' => 'set_image', 'target' => $std, 'path' => 'sections.1.title', 'libraryPath' => '/assets/images/second.webp', 'alt' => 'x'], 'is not an image', 'set_image on text');
csRejected($a, ['type' => 'set_image', 'target' => $std, 'path' => 'sections.2.src', 'libraryPath' => '/assets/images/second.webp'], 'needs an alt text', 'image without alt');
csRejected($a, ['type' => 'set_image', 'target' => $std, 'path' => 'sections.2.src', 'libraryPath' => '/assets/images/missing.webp', 'alt' => 'x'], 'not in the media library', 'unknown library image');
csRejected($a, ['type' => 'set_image', 'target' => $std, 'path' => 'sections.2.src', 'libraryPath' => '../../admin/config.php', 'alt' => 'x'], 'not in the media library', 'traversal as image path');
csRejected($a, ['type' => 'set_image', 'target' => $std, 'path' => 'sections.2.src', 'mediaRef' => 'm_nope1234', 'alt' => 'x'], 'unknown image reference', 'unknown mediaRef');
csRejected($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.1.content', 'value' => ['array']], 'needs an html string', 'wrong value type');
csRejected($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.1.content', 'value' => str_repeat('<p>word</p>', 3000)], 'longer than', 'html over the limit');
csRejected($a, ['type' => 'set_field', 'target' => 'event:2026-11-05-open-day', 'path' => 'url', 'value' => 'https://spam.example/x'], 'external link not allowed', 'external link nobody named');
csRejected($a, ['type' => 'set_field', 'target' => 'event:2026-11-05-open-day', 'path' => 'url', 'value' => 'javascript:alert(1)'], 'link is not allowed', 'javascript link');
csRejected($a, ['type' => 'set_field', 'target' => 'event:2026-11-05-open-day', 'path' => 'time', 'value' => '25:99'], 'HH:MM', 'invalid time');
csRejected($a, ['type' => 'set_field', 'target' => 'event:2026-11-05-open-day', 'path' => 'date', 'value' => '2026-12-01'], 'read-only', 'event date is identity');
csRejected($a, ['type' => 'set_field', 'target' => 'news:2026-10-01-demo-post', 'path' => 'slug', 'value' => 'other'], 'read-only', 'news slug is identity');
csRejected($a, ['type' => 'set_field', 'target' => 'news:2026-10-01-demo-post', 'path' => 'hidden', 'value' => 'true'], 'read-only', 'news visibility');
csRejected($a, ['type' => 'section_insert', 'target' => $std, 'position' => 0, 'sectionType' => 'nuke', 'fields' => []], "unknown section type 'nuke'", 'unknown section type');
csRejected($a, ['type' => 'section_insert', 'target' => $std, 'position' => 0, 'sectionType' => 'text', 'fields' => ['onclick' => 'x']], "not a field of a text section", 'unknown section field');
csRejected($a, ['type' => 'section_insert', 'target' => $std, 'position' => 0, 'sectionType' => 'image', 'fields' => ['src' => '/assets/images/second.webp']], 'needs an alt text', 'image section without alt');
csRejected($a, ['type' => 'create_news', 'lang' => 'xx', 'title' => 'T', 'date' => '2026-10-10', 'contentHtml' => '<p>x</p>'], 'not a language of this site', 'unknown language');
csRejected($a, ['type' => 'create_news', 'lang' => 'en', 'title' => 'T', 'date' => '2026-13-40', 'contentHtml' => '<p>x</p>'], 'YYYY-MM-DD', 'impossible date');
csRejected($a, ['type' => 'create_news', 'lang' => 'en', 'title' => 'Demo post', 'slug' => 'demo-post', 'date' => '2026-10-01', 'contentHtml' => '<p>x</p>'], 'already exists', 'news id collision with a stored post');
csRejected($a, ['type' => 'create_news', 'lang' => 'en', 'title' => 'T', 'date' => '2026-10-10'], "'contentHtml' is required", 'missing news body');
csRejected($a, ['type' => 'create_news', 'lang' => 'en', 'title' => 'T', 'date' => '2026-10-10', 'contentHtml' => '<p>x</p>', 'imagePath' => '/assets/images/nope.webp'], 'not in the media library', 'news image outside the library');
csRejected($a, ['type' => 'create_event', 'lang' => 'en', 'title' => 'T', 'date' => '2026-10-10', 'time' => '10:00'], "'location' is required", 'event without location');
csRejected($a, ['type' => 'create_page', 'lang' => 'en', 'slug' => 'Bad Slug', 'title' => 'T', 'sections' => [['type' => 'text', 'fields' => []]]], 'not valid', 'invalid page path');
csRejected($a, ['type' => 'create_page', 'lang' => 'en', 'slug' => 'scan-custom', 'title' => 'T', 'sections' => [['type' => 'text', 'fields' => []]]], 'template already serves', 'page that collides with a template');
csRejected($a, ['type' => 'create_page', 'lang' => 'en', 'slug' => 'scan-standard', 'title' => 'T', 'sections' => [['type' => 'text', 'fields' => []]]], 'already exists', 'page that already exists');
csRejected($a, ['type' => 'create_page', 'lang' => 'en', 'slug' => 'empty-page', 'title' => 'T', 'sections' => []], 'at least one section', 'page without sections');
csRejected($a, ['type' => 'revert_op', 'opId' => 'op_99'], 'unknown operation', 'reverting an unknown operation');
csAssert(csContent($site) === $pristine && raijuAgentChangesetLoad($a)['ops'] === [], 'refused requests changed neither content nor the change set');
csAssert(raijuAgentChangesetStage('cs_0000000000000000', ['type' => 'set_field'])['error'] === 'Unknown change set.' && raijuAgentChangesetStage('../../x', ['type' => 'set_field'])['error'] === 'Unknown change set.', 'unknown or malformed change set ids');
// ---- Values are normalised ---------------------------------------------------------
$r = csOk($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.1.title', 'value' => " A <b>bold</b> headline\n with breaks " . str_repeat('x', 400)], 'long text');
$stored = raijuAgentChangesetLoad($a)['ops'][0]['value'];
csAssert(!str_contains($stored, '<') && !str_contains($stored, "\n") && mb_strlen($stored) <= 160 && str_starts_with($stored, 'A bold headline with breaks') && $r['warnings'] !== [], 'text: tags stripped, whitespace collapsed, length capped with a warning: ' . $stored);
csOk($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.1.content', 'value' => '<p onclick="x()">Hello <script>alert(1)</script><strong>world</strong> <a href="javascript:alert(1)">bad</a> <a href="https://example.org/source">good</a></p><iframe src="https://evil.example"></iframe>'], 'html with active content');
$html = raijuAgentChangesetLoad($a)['ops'][1]['value'];
csAssert(!preg_match('/onclick|script|iframe|javascript/i', $html) && str_contains($html, '<strong>world</strong>') && str_contains($html, 'https://example.org/source') && raijuSanitizeRichHtml($html) === $html, 'html: sanitised and stable under a second pass: ' . $html);
csOk($a, ['type' => 'set_image', 'target' => $std, 'path' => 'sections.2.src', 'libraryPath' => '/assets/images/second.webp', 'alt' => ' A <i>photo</i> of things '], 'split image');
$op = raijuAgentChangesetLoad($a)['ops'][2];
csAssert($op['src'] === '/assets/images/second.webp' && $op['alt'] === 'A photo of things' && $op['spec']['fmt'] === 'split' && $op['spec']['altPath'] === 'sections.2.alt', 'section image: library path, plain alt text, split format');
csOk($a, ['type' => 'set_field', 'target' => 'event:2026-11-05-open-day', 'path' => 'url', 'value' => 'https://example.org/source/'], 'event url named by the user');
csOk($a, ['type' => 'set_field', 'target' => 'news:2026-10-01-demo-post', 'path' => 'excerpt', 'value' => 'Fresh excerpt'], 'news excerpt');
csOk($a, ['type' => 'set_field', 'target' => 'event:2026-11-05-open-day', 'path' => 'title.de', 'value' => 'Neuer Titel'], 'event title in another language');
csOk($a, ['type' => 'set_field', 'target' => 'page:footer', 'path' => 'tagline.en', 'value' => 'We build things'], 'shared footer text');
// Section edits follow the state of the package: insert first, then edit the new section and the shifted one
csOk($a, ['type' => 'section_insert', 'target' => $std, 'position' => 0, 'sectionType' => 'text', 'fields' => ['title' => 'Lead-in', 'content' => '<p>Hello</p>']], 'section insert');
$r = csOk($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.0.title', 'value' => 'Lead-in (edited)'], 'editing the section inserted before');
csAssert($r['preview']['before'] === 'Lead-in', 'the index refers to the package state: ' . json_encode($r['preview']));
csRejected($a, ['type' => 'revert_op', 'opId' => 'op_8'], 'later operations depend', 'reverting an operation that later ones build on');
// Creating new content
csOk($a, ['type' => 'create_news', 'lang' => 'en', 'title' => 'Agent news: a "title"', 'date' => '2026-10-10', 'author' => 'Agent', 'excerpt' => 'Short summary', 'contentHtml' => '<h2>Heading</h2><p>Text <a href="https://example.org/source">source</a></p>', 'imagePath' => '/assets/images/library.webp'], 'news');
csOk($a, ['type' => 'create_event', 'lang' => 'en', 'title' => 'Launch party', 'date' => '2026-12-01', 'time' => '18:00', 'location' => 'Berlin', 'description' => '<p>Join us</p>', 'url' => 'mailto:team@example.org'], 'event');
csOk($a, ['type' => 'create_page', 'lang' => 'en', 'slug' => 'agent/page', 'title' => 'Agent page', 'description' => 'About the agent', 'sections' => [['type' => 'heading', 'fields' => ['text' => 'Agent page']], ['type' => 'text', 'fields' => ['title' => 'Intro', 'content' => '<p>Welcome</p>']]]], 'page');
csRejected($a, ['type' => 'create_news', 'lang' => 'en', 'title' => 'Agent news: a "title"', 'date' => '2026-10-10', 'contentHtml' => '<p>twice</p>'], 'already exists', 'creating the same news twice in one package');
$ops = raijuAgentChangesetLoad($a)['ops'];
csAssert(count($ops) === 12 && $ops[9]['post']['id'] === '2026-10-10-agent-news-a-title' && $ops[9]['post']['hidden'] === true, 'news id from date and slug, created hidden: ' . ($ops[9]['post']['id'] ?? ''));
csAssert($ops[10]['event']['hidden'] === true && $ops[10]['event']['url'] === 'mailto:team@example.org', 'event created hidden');
csAssert($ops[11]['page']['sections'][0]['type'] === 'heading' && $ops[11]['page']['sections'][0]['level'] === 'h1' && !isset($ops[11]['page']['sections'][1]['level']), 'the heading that opens a new page is its title (h1): ' . json_encode($ops[11]['page']['sections'][0]));
$page = $ops[11]['page'];
csAssert($page['visibility']['status'] === 'private' && strlen($page['visibility']['passwordHash']) > 40 && $page['nav'] === [] && count($page['sections']) === 2, 'page created private, random password, out of the navigation');
csAssert($ops[11]['target'] === 'page:en_agent__page', 'nested path becomes a flat content key');
csAssert(csContent($site) === $pristine, 'staging never touches stored content');
csAssert(is_dir($site . '/content/ai-agent/changesets') && !glob($site . '/content/ai-agent/changesets/*.tmp'), 'change sets live under content/ai-agent');
// ---- Review: validation, render check, fingerprint ------------------------------------
$report = raijuAgentChangesetFinalize($a);
csAssert($report['status'] === 'ready' && $report['validation']['ok'] && $report['renderCheck']['status'] === 'passed', 'the package is ready: ' . json_encode($report));
$targets = $report['renderCheck']['targets'];
foreach ([$std, 'news:2026-10-01-demo-post', 'event:2026-11-05-open-day', 'news:2026-10-10-agent-news-a-title', 'event:2026-12-01-launch-party', 'page:en_agent__page'] as $target) {
csAssert(($targets[$target]['status'] ?? '') === 'passed', "{$target} passes the render check: " . json_encode($targets[$target] ?? null));
}
csAssert($targets['page:footer']['status'] === 'skipped' && str_contains($targets['page:footer']['messages'][0], 'not checked by rendering'), 'shared content is skipped with a visible note');
csAssert(preg_match('/^[a-f0-9]{64}$/', $report['previewHash']) === 1 && raijuAgentChangesetFinalize($a)['previewHash'] === $report['previewHash'], 'the preview hash is stable');
csAssert(csContent($site) === $pristine, 'validating and rendering write nothing to content');
csAssert(!is_dir($site . '/content/analytics'), 'the render check is not counted as a visit');
$review = raijuAgentChangesetPreview(raijuAgentChangesetLoad($a));
csAssert($review['status'] === 'ready' && $review['conflicts'] === [] && $review['previewHash'] === $report['previewHash'], 'review data identity');
$first = $review['targets'][$std][0];
csAssert($first['opId'] === 'op_1' && $first['before'] === 'A text' && str_starts_with($first['after'], 'A bold headline') && $first['html'] === false, 'before/after of a text change');
csAssert(count($review['targets']) === 7 && $review['targets']['page:en_agent__page'][0]['type'] === 'create_page', 'operations are grouped by target');
$labels = array_map(static fn($ops) => array_column($ops, 'label'), $review['targets']);
csAssert($labels[$std][0] === 'Change “Title” in section 2 (text): “A text”', 'a review label says what changes and where: ' . $labels[$std][0]);
csAssert($labels[$std][2] === 'Set image “Image” in section 3 (image)' && $labels[$std][3] === 'Add text section', 'image and section labels: ' . json_encode($labels[$std]));
csAssert($labels['page:en_agent__page'][0] === 'Create page' && !str_contains(implode('|', $labels[$std]), 'sections.'), 'no raw paths in the labels');
$encoded = json_encode($review, JSON_UNESCAPED_SLASHES);
csAssert(str_contains($encoded, '<p>Hello <strong>world</strong>') && !preg_match('/onclick|<script|<iframe|javascript:/i', $encoded), 'HTML values in the review are the sanitised ones');
// ---- Apply ----------------------------------------------------------------------------
$beforeApply = csContent($site);
$hash = $report['previewHash'];
csAssert(str_contains(raijuAgentChangesetApply($a, 'not-the-hash', $owner)['error'], 'changed after the preview'), 'a wrong preview hash is refused');
csAssert(raijuAgentChangesetApply($a, $hash, $stranger)['error'] === 'Unknown change set.', 'another user cannot apply it');
csAssert(csContent($site) === $beforeApply, 'refused applies change nothing');
$applied = raijuAgentChangesetApply($a, $hash, $owner);
csAssert($applied['ok'] && $applied['status'] === 'applied' && raijuAgentVerifyUndoToken($a, $applied['undoToken']), 'apply: ' . json_encode($applied));
$page = csDoc($site, 'content/pages/en_scan-standard.json');
csAssert(count($page['sections']) === 5 && $page['sections'][0]['title'] === 'Lead-in (edited)' && $page['sections'][0]['hidden'] === true && str_starts_with($page['sections'][0]['id'], 'section_'), 'section inserted hidden, edited in the same package');
csAssert($page['sections'][2]['title'] === 'A bold headline with breaks' . substr($page['sections'][2]['title'], 27) && !str_contains($page['sections'][2]['content'], 'script'), 'text and html changes landed on the shifted section');
csAssert($page['sections'][3]['src'] === '/assets/images/second.webp' && $page['sections'][3]['alt'] === 'A photo of things', 'the image change followed its section');
csAssert(isset($page['lastModified']), 'modification time set');
csAssert(csDoc($site, 'content/news/2026-10-01-demo-post.json')['excerpt'] === 'Fresh excerpt', 'news edited');
$events = csDoc($site, 'content/events.json')['events'];
csAssert($events[0]['title']['de'] === 'Neuer Titel' && $events[0]['url'] === 'https://example.org/source/' && $events[1]['hidden'] === true && $events[1]['id'] === '2026-12-01-launch-party', 'events edited and created hidden');
csAssert(csDoc($site, 'content/pages/footer.json')['tagline']['en'] === 'We build things', 'shared content edited');
$post = csDoc($site, 'content/news/2026-10-10-agent-news-a-title.json');
csAssert($post['hidden'] === true && $post['image'] === '/assets/images/library.webp' && isset($post['lastModified']), 'new news post is hidden');
$newPage = csDoc($site, 'content/pages/en_agent__page.json');
csAssert($newPage['visibility']['status'] === 'private' && $newPage['nav'] === [], 'new page is private and not in the navigation');
$backups = array_map('basename', glob($site . '/backups/*.json'));
csAssert(count(array_filter($backups, fn($b) => str_starts_with($b, 'en_scan-standard_'))) === 1 && count(array_filter($backups, fn($b) => str_starts_with($b, 'news_2026-10-01-demo-post_'))) === 1
&& count(array_filter($backups, fn($b) => str_starts_with($b, 'events_'))) === 1 && count(array_filter($backups, fn($b) => str_starts_with($b, 'footer_'))) === 1 && count($backups) === 4, 'the usual backups exist for the four existing files: ' . implode(', ', $backups));
csAssert(count(glob($site . '/content/ai-agent/snapshots/' . $a . '/apply/*')) === 4 && !glob($site . '/content/ai-agent/journal/*'), 'snapshots kept, journal gone');
$stored = raijuAgentChangesetLoad($a);
csAssert($stored['status'] === 'applied' && count($stored['applied']['items']) === 4 && array_column($stored['applied']['items'], 'kind') === ['news', 'event', 'page', 'section'], 'publishable items: ' . json_encode(array_column($stored['applied']['items'], 'kind')));
foreach ($stored['applied']['revisions'] as $relative => $revision) {
csAssert(raijuJsonRevision($site . '/' . $relative) === $revision, "revision of {$relative} recorded");
}
csAssert(str_contains(raijuAgentChangesetApply($a, $hash, $owner)['error'], 'applied and cannot be applied'), 'a change set applies once');
$audit = '';
foreach (glob($site . '/content/ai-audit/*.jsonl') ?: [] as $file) $audit .= file_get_contents($file);
csAssert(str_contains($audit, '"agent-apply"') && str_contains($audit, $a) && !str_contains($audit, 'Fresh excerpt') && !str_contains($audit, 'Lead-in') && !str_contains($audit, 'Agent news'), 'audit records the apply with hashes, not content');
// ---- Publish ------------------------------------------------------------------------------
$newsItem = ['kind' => 'news', 'target' => 'news:2026-10-10-agent-news-a-title'];
csAssert(raijuAgentChangesetPublish($a, $newsItem, $owner)['error'] === 'You are not allowed to publish.', 'publishing needs the publish right');
csAssert(raijuAgentChangesetPublish($a, $newsItem, $stranger)['error'] === 'Unknown change set.', 'only the owner (or an admin) can publish');
csAssert(str_contains(raijuAgentChangesetPublish($a, ['kind' => 'news', 'target' => 'news:2026-10-01-demo-post'], $publisher)['error'], 'not part of the change set'), 'only items of the change set can be published');
csAssert(raijuAgentChangesetPublish($a, $newsItem, $publisher)['ok'] && !isset(csDoc($site, 'content/news/2026-10-10-agent-news-a-title.json')['hidden']), 'news published');
csAssert(str_contains(raijuAgentChangesetPublish($a, $newsItem, $publisher)['error'], 'already published'), 'publishing twice is refused');
csAssert(raijuAgentChangesetPublish($a, ['kind' => 'event', 'target' => 'event:2026-12-01-launch-party'], $publisher)['ok'] && !isset(csDoc($site, 'content/events.json')['events'][1]['hidden']), 'event published');
csAssert(raijuAgentChangesetPublish($a, ['kind' => 'page', 'target' => 'page:en_agent__page'], $publisher)['ok'] && !isset(csDoc($site, 'content/pages/en_agent__page.json')['visibility']), 'page published (no longer private)');
$sectionId = $page['sections'][0]['id'];
csAssert(raijuAgentChangesetPublish($a, ['kind' => 'section', 'target' => $std, 'sectionId' => $sectionId], $publisher)['ok'] && !isset(csDoc($site, 'content/pages/en_scan-standard.json')['sections'][0]['hidden']), 'section published');
// ---- Undo -----------------------------------------------------------------------------------
$token = $applied['undoToken'];
csAssert(str_contains(raijuAgentChangesetUndo($a, $a . '.deadbeef', $owner)['error'], 'not possible'), 'a forged undo token is refused');
csAssert(str_contains(raijuAgentChangesetUndo($a, $token, $stranger)['error'], 'not possible'), 'another user cannot undo');
$demo = $site . '/content/news/2026-10-01-demo-post.json';
$original = (string)file_get_contents($demo);
file_put_contents($demo, str_replace('Fresh excerpt', 'Edited by someone else', $original));
$refused = raijuAgentChangesetUndo($a, $token, $owner);
csAssert(!$refused['ok'] && str_contains($refused['error'], '2026-10-01-demo-post.json was changed after'), 'undo is refused after a foreign change: ' . json_encode($refused));
csAssert(raijuAgentChangesetLoad($a)['status'] === 'applied' && str_contains((string)file_get_contents($demo), 'Edited by someone else'), 'a refused undo changes nothing');
file_put_contents($demo, $original);
$undone = raijuAgentChangesetUndo($a, $token, $owner);
csAssert($undone['ok'] && $undone['status'] === 'undone', 'undo after the file matches again, even after publishing: ' . json_encode($undone));
csAssert(csContent($site) === array_diff_key($beforeApply, ['en_agent__page.json' => 1]) + [], 'every existing file is byte-identical to its state before the apply, created files are gone');
csAssert(!is_file($site . '/content/news/2026-10-10-agent-news-a-title.json') && glob($site . '/content/ai-agent/snapshots/' . $a . '/undone-*'), 'created news removed, a copy kept');
csAssert(glob($site . '/content/pages-trash/en_agent__page_*.json') && !is_file($site . '/content/pages/en_agent__page.json'), 'the created page went to the pages trash');
csAssert(str_contains(raijuAgentChangesetUndo($a, $token, $owner)['error'], 'Only an applied change set'), 'undo works once');
csAssert(raijuAgentChangesetLoad($a)['status'] === 'undone', 'status undone');
// ---- Conflicts ------------------------------------------------------------------------------
$c = csNew();
csOk($c, ['type' => 'set_field', 'target' => 'news:2026-10-01-demo-post', 'path' => 'excerpt', 'value' => 'Excerpt v1'], 'news excerpt');
$ready = raijuAgentChangesetFinalize($c);
csAssert($ready['status'] === 'ready', 'conflict package is ready first');
file_put_contents($demo, str_replace('"title":"Demo post"', '"title":"Renamed elsewhere"', (string)file_get_contents($demo)));
$res = raijuAgentChangesetApply($c, $ready['previewHash'], $owner);
csAssert(!$res['ok'] && $res['status'] === 'conflict' && str_contains($res['error'], 'content changed since'), 'apply detects the changed base: ' . json_encode($res));
csAssert(raijuAgentChangesetLoad($c)['status'] === 'conflict' && str_contains(csStage($c, ['type' => 'set_field'])['error'], 'conflict'), 'the change set is marked conflict and closed for new operations');
csAssert(!raijuAgentChangesetRevalidate($c, $stranger)['ok'], 'only the owner can revalidate');
csAssert(raijuAgentChangesetRevalidate($c, $owner)['ok'] && raijuAgentChangesetLoad($c)['status'] === 'staging', 'revalidate replays on the new base');
$ready = raijuAgentChangesetFinalize($c);
csAssert($ready['status'] === 'ready' && raijuAgentChangesetApply($c, $ready['previewHash'], $owner)['ok'], 'and the package applies on top of the foreign change');
$news = csDoc($site, 'content/news/2026-10-01-demo-post.json');
csAssert($news['title'] === 'Renamed elsewhere' && $news['excerpt'] === 'Excerpt v1', 'the foreign edit and the agent edit are both there');
$d = csNew();
csOk($d, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.3.title', 'value' => 'Edited card'], 'card title');
$ready = raijuAgentChangesetFinalize($d);
$stdFile = $site . '/content/pages/en_scan-standard.json';
$stdOriginal = (string)file_get_contents($stdFile);
file_put_contents($stdFile, json_encode(['page' => 'en_scan-standard', 'lang' => 'en', 'title' => 'Shrunk', 'sections' => [['id' => 'h1', 'type' => 'heading', 'text' => 'Only one']]]));
csAssert(raijuAgentChangesetFinalize($d)['status'] === 'conflict', 'finalize reports the conflict');
$rev = raijuAgentChangesetRevalidate($d, $owner);
csAssert(!$rev['ok'] && str_contains($rev['error'], 'no longer fit') && raijuAgentChangesetLoad($d)['status'] === 'conflict', 'operations that no longer fit stay a conflict, with the reason: ' . json_encode($rev));
file_put_contents($stdFile, $stdOriginal);
// ---- Menus: a page shown in the main menu (administrators' tool) -------------------------------
$menuCs = csNew();
csOk($menuCs, ['type' => 'create_page', 'lang' => 'en', 'slug' => 'menu-page', 'title' => 'Menu page', 'sections' => [['type' => 'heading', 'fields' => ['text' => 'Menu page']]]], 'page for the menu');
csRejected($menuCs, ['type' => 'set_page_menus', 'target' => 'page:en_menu-page', 'add' => ['nope']], 'is not a menu of this site', 'unknown menu');
csRejected($menuCs, ['type' => 'set_page_menus', 'target' => 'news:2026-10-01-demo-post', 'add' => ['header']], 'Menus belong to pages', 'menus of a news post');
csRejected($menuCs, ['type' => 'set_page_menus', 'target' => 'page:en_does-not-exist', 'add' => ['header']], 'does not exist', 'menus of a missing page');
csRejected($menuCs, ['type' => 'set_page_menus', 'target' => 'page:en_menu-page'], 'at least one menu', 'nothing to change');
csRejected($menuCs, ['type' => 'set_page_menus', 'target' => 'page:en_menu-page', 'add' => ['header'], 'remove' => ['header']], 'added and removed at once', 'the same menu added and removed');
csRejected($menuCs, ['type' => 'set_page_menus', 'target' => 'page:en_menu-page', 'add' => ['header'], 'nav' => ['x']], "'nav' is not an argument", 'no raw nav argument');
csOk($menuCs, ['type' => 'set_page_menus', 'target' => 'page:en_menu-page', 'add' => ['header']], 'menus of the new page');
csOk($menuCs, ['type' => 'set_page_menus', 'target' => 'page:en_menu-page', 'remove' => ['header']], 'and taken out again');
csOk($menuCs, ['type' => 'set_page_menus', 'target' => 'page:en_menu-page', 'add' => ['header']], 'and put back');
csRejected($menuCs, ['type' => 'set_field', 'target' => $std, 'path' => 'nav', 'value' => 'header'], 'not an editable field', 'the nav list is still no editable field');
$menuReview = raijuAgentChangesetPreview(raijuAgentChangesetLoad($menuCs));
$menuOps = $menuReview['targets']['page:en_menu-page'];
csAssert($menuOps[1]['label'] === 'Change the menus of this page' && $menuOps[1]['before'] === '–' && $menuOps[1]['after'] === 'Header', 'the review names the menus: ' . json_encode($menuOps[1]));
csAssert($menuOps[2]['before'] === 'Header' && $menuOps[2]['after'] === '–', 'and what changes: ' . json_encode($menuOps[2]));
$menuReady = raijuAgentChangesetFinalize($menuCs);
csAssert($menuReady['status'] === 'ready', 'the menu package is ready: ' . json_encode($menuReady['validation'] ?? $menuReady));
csAssert(array_filter((array)$menuReady['validation']['warnings'], static fn($w) => str_contains($w['message'], 'private')) !== [], 'a private page in the main menu is flagged');
$menuApplied = raijuAgentChangesetApply($menuCs, $menuReady['previewHash'], $owner);
csAssert(!empty($menuApplied['ok']), 'the menu package applies: ' . json_encode($menuApplied));
$menuPage = csDoc($site, 'content/pages/en_menu-page.json');
csAssert($menuPage['nav'] === ['header'], 'the page is in the main menu only: ' . json_encode($menuPage['nav']));
csAssert(!empty(raijuAgentChangesetUndo($menuCs, $menuApplied['undoToken'], $owner)['ok']) && !is_file($site . '/content/pages/en_menu-page.json'), 'undo takes the page and its menu entry away');
// ---- A failing write rolls everything back ----------------------------------------------------
$f = csNew();
csOk($f, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.1.title', 'value' => 'Rollback me'], 'page edit');
csOk($f, ['type' => 'set_field', 'target' => 'news:2026-10-01-demo-post', 'path' => 'excerpt', 'value' => 'Rollback excerpt'], 'news edit');
csOk($f, ['type' => 'set_field', 'target' => 'event:2026-11-05-open-day', 'path' => 'title.de', 'value' => 'Rollback Titel'], 'event edit');
csOk($f, ['type' => 'create_news', 'lang' => 'en', 'title' => 'Rollback news', 'date' => '2026-10-11', 'contentHtml' => '<p>x</p>', 'imagePath' => '/assets/images/library.webp'], 'news create');
$ready = raijuAgentChangesetFinalize($f);
csAssert($ready['status'] === 'ready', 'rollback package ready');
$before = csContent($site);
$writes = 0;
$res = raijuAgentChangesetApply($f, $ready['previewHash'], $owner, ['testHook' => static function (string $event, string $file) use (&$writes): void {
if (++$writes === 3) throw new RuntimeException('disk full (simulated)');
}]);
csAssert(!$res['ok'] && $res['status'] === 'failed' && str_contains($res['error'], 'nothing was changed') && $writes === 3, 'a write error is reported: ' . json_encode($res));
csAssert(csContent($site) === $before && !is_file($site . '/content/news/2026-10-11-rollback-news.json'), 'three files were written, then all of them were restored and the new file removed');
csAssert(!glob($site . '/content/ai-agent/journal/*') && raijuAgentChangesetLoad($f)['status'] === 'failed' && raijuAgentChangesetLoad($f)['applied'] === null, 'journal removed, change set failed, nothing recorded as applied');
csAssert(str_contains(raijuAgentChangesetApply($f, $ready['previewHash'], $owner)['error'], 'failed and cannot be applied'), 'a failed change set is not retried');
// ---- A crash in the middle of an apply is repaired -----------------------------------------------
$k = csNew();
csOk($k, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.1.title', 'value' => 'Crash edit'], 'page edit');
csOk($k, ['type' => 'set_field', 'target' => 'news:2026-10-01-demo-post', 'path' => 'excerpt', 'value' => 'Crash excerpt'], 'news edit');
csOk($k, ['type' => 'create_news', 'lang' => 'en', 'title' => 'Crash news', 'date' => '2026-10-12', 'contentHtml' => '<p>x</p>', 'imagePath' => '/assets/images/library.webp'], 'news create');
csAssert(raijuAgentChangesetFinalize($k)['status'] === 'ready', 'crash package ready');
$before = csContent($site);
$crash = csWorker($site, 'crash', $k);
csAssert($crash['_code'] === 9 && count(glob($site . '/content/ai-agent/journal/*')) === 1, 'the process died with a journal left behind');
csAssert(csContent($site) !== $before && raijuAgentChangesetLoad($k)['status'] === 'applying', 'the files are half written');
$k2 = csNew();
csOk($k2, ['type' => 'set_field', 'target' => 'event:2026-11-05-open-day', 'path' => 'title.de', 'value' => 'Anderer Titel'], 'event edit');
$ready2 = raijuAgentChangesetFinalize($k2);
$res = raijuAgentChangesetApply($k2, $ready2['previewHash'], $owner);
csAssert($res['ok'], 'the next apply goes through: ' . json_encode($res));
csAssert(raijuAgentChangesetLoad($k)['status'] === 'failed' && str_contains(raijuAgentChangesetLoad($k)['error'], 'interrupted') && !glob($site . '/content/ai-agent/journal/*'), 'and first restored the interrupted one');
$expected = $before;
csAssert(array_diff_assoc(csContent($site), $before) === ['events.json' => hash_file('sha256', $site . '/content/events.json')] && !is_file($site . '/content/news/2026-10-12-crash-news.json'), 'only the later change is in place; the interrupted files are as before: ' . json_encode(array_keys(array_diff_assoc(csContent($site), $before))));
raijuAgentChangesetUndo($k2, raijuAgentUndoToken($k2), $owner);
csAssert(raijuAgentRecoverJournals() === [], 'nothing left to recover');
// ---- Render check and validation block a package -------------------------------------------------
$m = csWorker($site, 'manual-op');
csAssert($m['finalize']['status'] === 'staging' && $m['finalize']['renderCheck']['status'] === 'failed' && str_contains(json_encode($m['finalize']['renderCheck']), 'hero.video does not appear'), 'a path the page does not render fails the render check: ' . json_encode($m));
$res = raijuAgentChangesetApply($m['id'], 'x', $owner);
csAssert(!$res['ok'] && $res['status'] === 'staging', 'a package that is not ready cannot be applied');
$n = csNew();
csOk($n, ['type' => 'create_news', 'lang' => 'en', 'title' => 'No picture', 'date' => '2026-10-13', 'contentHtml' => '<p>text</p>'], 'news without image');
$res = raijuAgentChangesetFinalize($n);
csAssert($res['status'] === 'staging' && !$res['validation']['ok'] && str_contains($res['validation']['errors'][0]['message'], 'requires an image') && $res['renderCheck']['status'] === 'skipped', 'site rule: every news post needs an image');
csAssert(raijuAgentChangesetFinalize(csNew())['validation']['errors'][0]['message'] === 'The change set is empty.', 'an empty package is not ready');
$g = csNew();
raijuAgentChangesetMutate($g, static function (array &$cs) { $cs['media']['m_test1'] = ['finalPath' => '/assets/images/library.webp']; return true; });
csOk($g, ['type' => 'set_image', 'target' => $std, 'path' => 'sections.2.src', 'mediaRef' => 'm_test1', 'alt' => 'Staged image'], 'staged image');
$ready = raijuAgentChangesetFinalize($g);
$before = csContent($site);
$res = raijuAgentChangesetApply($g, $ready['previewHash'], $owner);
csAssert($ready['status'] === 'ready' && !$res['ok'] && str_contains($res['error'], 'staged image m_test1 is missing') && csContent($site) === $before && raijuAgentChangesetLoad($g)['status'] === 'failed', 'a staged image that is gone fails the apply without side effects: ' . json_encode($res));
// ---- Ownership, discard, cleanup ----------------------------------------------------------------------
$o = csNew();
csOk($o, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.1.title', 'value' => 'Discard me'], 'edit');
mkdir($site . '/content/ai-agent/staging/' . $o . '/media', 0755, true);
csAssert(!raijuAgentChangesetDiscard($o, $stranger) && raijuAgentChangesetLoad($o)['status'] === 'staging', 'a stranger cannot discard it');
csAssert(raijuAgentChangesetDiscard($o, $owner) && raijuAgentChangesetLoad($o)['status'] === 'discarded' && !is_dir($site . '/content/ai-agent/staging/' . $o), 'the owner discards it, staging files go');
csAssert(str_contains(csStage($o, ['type' => 'set_field'])['error'], 'discarded'), 'a discarded change set is closed');
$o2 = csNew();
csAssert(raijuAgentChangesetDiscard($o2, $admin), 'an admin can discard anyone\'s change set');
$stale = raijuAgentChangesetLoad($o);
$stale['updatedAt'] = date('c', time() - 2 * 86400); // written directly: the store stamps every normal update
raijuJsonAtomicWrite(raijuAgentChangesetPath($o), $stale);
touch($site . '/content/ai-agent/snapshots/' . $a, time() - 40 * 86400);
$removed = raijuAgentCleanup();
csAssert($removed['changesets'] >= 1 && !is_file(raijuAgentChangesetPath($o)) && $removed['snapshots'] === 1 && !is_dir($site . '/content/ai-agent/snapshots/' . $a), 'old unfinished change sets and 30-day-old snapshots are cleaned up: ' . json_encode($removed));
csAssert(is_file(raijuAgentChangesetPath($c)) && is_dir($site . '/content/ai-agent/snapshots/' . $c), 'applied change sets and recent snapshots stay');
// ---- A custom template page: fields, links, images in every format, lists ---------------------------
$customFile = $site . '/content/pages/en_scan-custom.json';
$customBefore = (string)file_get_contents($customFile);
$custom = csWorker($site, 'custom-stage');
csAssert(isset($custom['results']), 'custom staging worker ran: ' . json_encode($custom));
$R = $custom['results'];
$ok = static fn(string $key) => !empty($R[$key]['ok']);
$err = static fn(string $key, string $needle) => isset($R[$key]['error']) && stripos($R[$key]['error'], $needle) !== false;
csAssert($ok('title') && $ok('titleTooLong') && $R['titleTooLong']['warnings'] !== [], 'a text above the site limit (40) is shortened with a warning');
csAssert($ok('linkOk') && $ok('linkInternal') && $err('linkExternal', 'external link not allowed') && $err('linkJs', 'link is not allowed'), 'links: named or internal pass, others are refused: ' . json_encode([$R['linkOk'], $R['linkExternal']]));
csAssert($err('readOnly', 'read-only') && $err('structural', 'read-only') && $err('sensitive', 'read-only'), 'override, structural and sensitive fields are read-only');
csAssert($ok('imageObject') && $ok('imageSplit') && $ok('imageString') && $err('imageNoAlt', 'needs an alt text'), 'object, split and string images');
csAssert($ok('insert') && $err('insertUnknown', 'not a field of this list') && $ok('itemTitle') && $R['itemTitle']['preview']['before'] === 'Inserted', 'list item fields follow the state of the package');
csAssert($err('itemOutOfRange', 'the list has 4 items') && $err('itemUnknownField', 'not a field of') && $err('hideRange', 'out of range') && $err('moveRange', 'out of range') && $err('notAList', 'not an editable list'), 'list bounds and field names are checked: ' . json_encode([$R['itemOutOfRange'], $R['notAList']]));
csAssert($ok('hide') && $err('removeWrong', 'starts with "Three"') && $ok('move'), 'removal asks for the title prefix of the item');
csAssert($ok('faq') && $err('faqLink', 'external link not allowed') && $err('sectionOnTemplate', 'standard pages'), 'defaults-only lists, links inside list items, no sections on templates');
csAssert($custom['items'] === [['Three', false], ['One', true], ['Inserted (edited)', true], ['Two', true]] && $custom['isList'] === true && $custom['opCount'] === 12, 'resulting list (hidden flags, order) and array form: ' . json_encode($custom['items']));
csAssert(file_get_contents($customFile) === $customBefore, 'staging a template page writes nothing');
$cid = $custom['id'];
$pending = $custom['sameProcessFinalize'];
csAssert($pending['status'] === 'staging' && $pending['validation']['ok'] && $pending['renderCheck']['status'] === 'pending', 'a second template render in one process waits for the next request: ' . json_encode($pending['renderCheck']));
$fin = csWorker($site, 'finalize', $cid)['finalize'];
csAssert($fin['status'] === 'ready' && $fin['renderCheck']['targets']['page:en_scan-custom']['status'] === 'passed', 'the template is rendered with the package (shifted list paths included): ' . json_encode($fin['renderCheck']));
$res = csWorker($site, 'apply', $cid)['apply'];
csAssert($res['ok'], 'apply: ' . json_encode($res));
$doc = json_decode((string)file_get_contents($customFile), true);
csAssert($doc['hero']['title'] === str_repeat('x', 40) && $doc['hero']['more'] === ['text' => 'Contact', 'href' => '/contact', 'target' => '_blank'], 'text and link (the link keeps its target)');
csAssert($doc['hero']['image'] === ['src' => '/assets/images/second.webp', 'alt' => 'Second image'] && $doc['hero']['photo'] === '/assets/images/second.webp' && $doc['hero']['photoAlt'] === 'Split alt' && $doc['gallery']['cover'] === '/assets/images/second.webp', 'images in object, split and string format');
csAssert(array_is_list($doc['features']['items']) && array_column($doc['features']['items'], 'title') === ['Three', 'One', 'Inserted (edited)', 'Two'] && array_map(fn($i) => !empty($i['hidden']), $doc['features']['items']) === [false, true, true, true], 'list operations replayed in order, list written as an array');
csAssert($doc['features']['items'][2]['image'] === ['src' => '/assets/images/second.webp', 'alt' => 'Item image'] && $doc['features']['items'][2]['icon'] === 'bolt' && $doc['features']['items'][2]['text'] === 'Fresh text', 'inserted item merged with the template defaults');
csAssert($doc['faq']['items'] === [['question' => 'Why?', 'answer' => 'Because.', 'link' => ['text' => 'More', 'href' => 'https://example.org/allowed'], 'hidden' => true]], 'item for a list that had no items: defaults, link, hidden');
$before = json_decode($customBefore, true);
foreach (['contact', 'visibility', 'seo', 'about', 'page', 'lang'] as $untouched) csAssert($doc[$untouched] === $before[$untouched], "{$untouched} is untouched");
$stored = raijuAgentChangesetLoad($cid);
$listItems = array_values(array_filter($stored['applied']['items'], fn($i) => $i['kind'] === 'list_item'));
csAssert(count($listItems) === 2 && array_column($listItems, 'index') === [2, 0] && array_column($listItems, 'listKey') === ['features.items', 'faq.items'], 'new list items are publishable at their final position: ' . json_encode($listItems));
csAssert(raijuAgentChangesetPublish($cid, ['kind' => 'list_item', 'target' => 'page:en_scan-custom', 'listKey' => 'features.items', 'index' => 2], $publisher)['ok'], 'list item published');
csAssert(raijuAgentChangesetPublish($cid, ['kind' => 'list_item', 'target' => 'page:en_scan-custom', 'listKey' => 'faq.items', 'index' => 0], $publisher)['ok'], 'second list item published');
$doc = json_decode((string)file_get_contents($customFile), true);
csAssert(array_map(fn($i) => !empty($i['hidden']), $doc['features']['items']) === [false, true, false, true] && !isset($doc['faq']['items'][0]['hidden']), 'only the published items became visible; items that were hidden before stay hidden');
csAssert(raijuAgentChangesetUndo($cid, raijuAgentUndoToken($cid), $owner)['ok'] && file_get_contents($customFile) === $customBefore, 'undo restores the template page byte for byte');
$limits = csWorker($site, 'custom-limits');
csAssert(isset($limits['over']), 'limits worker: ' . json_encode($limits)); csAssert($limits['over'][0] === 3 && str_contains($limits['over'][1], 'would have 7 items; at most 6'), 'the list limit of the site is enforced while staging: ' . json_encode($limits['over']));
csAssert($limits['removeOps'] === 2 && count($limits['minFinalize']['validation']['errors']) === 1 && $limits['minFinalize']['status'] === 'staging' && str_contains($limits['minFinalize']['validation']['errors'][0]['message'], 'at least 2 are required'), 'a minimum is enforced when the package is presented, not while staging: ' . json_encode($limits));
csAssert(file_get_contents($customFile) === $customBefore, 'no limit test touched the page');
echo "PASS agent changesets: staging rules, normalisation, review, apply with backups and journal, publish, undo, conflicts, rollback, crash recovery, render check, custom template pages with lists, ownership, cleanup\n";