[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' => '

Body

', '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' => '

Come

'], 'admission' => ['en' => 'Free'], 'image' => '', ]]])); file_put_contents($site . '/content/pages/footer.json', json_encode(['tagline' => ['en' => 'We build'], 'copyright' => '© 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('

word

', 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' => '

x

'], 'not a language of this site', 'unknown language'); csRejected($a, ['type' => 'create_news', 'lang' => 'en', 'title' => 'T', 'date' => '2026-13-40', 'contentHtml' => '

x

'], 'YYYY-MM-DD', 'impossible date'); csRejected($a, ['type' => 'create_news', 'lang' => 'en', 'title' => 'Demo post', 'slug' => 'demo-post', 'date' => '2026-10-01', 'contentHtml' => '

x

'], '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' => '

x

', '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 bold 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' => '

Hello world bad good

'], 'html with active content'); $html = raijuAgentChangesetLoad($a)['ops'][1]['value']; csAssert(!preg_match('/onclick|script|iframe|javascript/i', $html) && str_contains($html, 'world') && 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 photo 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' => '

Hello

']], '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' => '

Heading

Text source

', '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' => '

Join us

', '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' => '

Welcome

']]]], 'page'); csRejected($a, ['type' => 'create_news', 'lang' => 'en', 'title' => 'Agent news: a "title"', 'date' => '2026-10-10', 'contentHtml' => '

twice

'], '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, '

Hello world') && !preg_match('/onclick| 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' => '

x

', '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' => '

x

', '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' => '

text

'], '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";