#!/usr/bin/env python3 """Preview of an AI change set on the real page: only for its owner, read-only, without the editor.""" import hashlib import json from pathlib import Path import subprocess import tempfile from support import Client, Site with tempfile.TemporaryDirectory(prefix="raiju-preview-") as folder: site = Site(folder).start() try: root = site.root def php(code): result = subprocess.run(["php", "-d", "display_errors=0", "-r", "define('SITE_LANG_DEFAULT','en'); require 'includes/ai/agent/changeset-apply.php'; " + code], cwd=root, text=True, capture_output=True) assert result.returncode == 0, result.stdout + result.stderr return result.stdout def content_hashes(): files = list((root / "content/pages").glob("*.json")) + list((root / "content/news").glob("*.json")) + [root / "content/events.json"] return {f.name: hashlib.sha256(f.read_bytes()).hexdigest() for f in files if f.exists()} owner = Client(site).login("editor") other = Client(site).login("admin") anonymous = Client(site) def get(client, path): status, headers, body = client.request(path) return status, {k.lower(): v for k, v in headers.items()}, body.decode("utf-8", "replace") # Opening a page as an editor completes missing fields in its JSON (auto-write). That is # normal; do it first, so the change set below starts from a settled file. status, headers, live = get(owner, "/services/test") assert status == 200 and "Public fixture" in live and "inline-editor.js" in live and "rj-agent-preview-banner" not in live, "live page for the editor" assert "noindex" not in headers.get("x-robots-tag", ""), headers staged = json.loads(php(""" $cs = raijuAgentChangesetCreate(['owner' => 'u_editor', 'runId' => 'preview']); $id = $cs['id']; $r = []; $r[] = raijuAgentChangesetStage($id, ['type' => 'set_field', 'target' => 'page:en_services__test', 'path' => 'sections.0.content', 'value' => '
Preview content from the agent
']); $r[] = raijuAgentChangesetStage($id, ['type' => 'create_news', 'lang' => 'en', 'title' => 'Agent preview news', 'slug' => 'agent-preview-news', 'date' => '2026-10-10', 'excerpt' => 'Preview excerpt', 'contentHtml' => 'News body from the agent
', 'imagePath' => '/assets/images/placeholder-image.webp']); $r[] = raijuAgentChangesetStage($id, ['type' => 'create_page', 'lang' => 'en', 'slug' => 'agent-new', 'title' => 'Agent new page', 'sections' => [['type' => 'text', 'fields' => ['title' => 'New page headline', 'content' => 'Private draft body
']]]]); echo json_encode(['id' => $id, 'results' => $r, 'finalize' => raijuAgentChangesetFinalize($id)]); """)) cs = staged["id"] assert all(r.get("ok") for r in staged["results"]), staged["results"] assert staged["finalize"]["status"] == "ready", staged["finalize"] before = content_hashes() url = f"/services/test?rj_agent_preview={cs}" # The owner sees the package on the real page status, headers, preview = get(owner, url) assert status == 200 and "Preview content from the agent" in preview and "Public fixture" not in preview, "overlay content: %s %s" % (status, (root / "server.log").read_text()[-1500:]) assert "rj-agent-preview-banner" in preview and "have not been applied yet" in preview, "banner" assert "inline-editor.js" not in preview and 'name="csrf-token"' not in preview and "ai-copilot.js" not in preview, "the inline editor must not load in a preview" assert headers.get("x-robots-tag") == "noindex, nofollow" and "no-store" in headers.get("cache-control", ""), headers # Hidden news and a private, not yet existing page are visible in the preview only status, _, body = get(owner, f"/news/agent-preview-news?rj_agent_preview={cs}") assert status == 200 and "Agent preview news" in body and "News body from the agent" in body, "preview of a hidden news post (status %s)" % status status, _, body = get(owner, "/news/agent-preview-news") assert status == 404, "the hidden post does not exist without the preview" status, _, body = get(owner, f"/agent-new?rj_agent_preview={cs}") assert status == 200 and "Private draft body" in body and "New page headline" in body, "preview of a new page (status %s)" % status assert get(owner, "/agent-new")[0] == 404, "the new page does not exist without the preview" # Everyone else gets the live content: other users, visitors, malformed or unknown ids for label, client, path in [("another user", other, url), ("a visitor", anonymous, url), ("a malformed id", owner, "/services/test?rj_agent_preview=../../x"), ("an unknown id", owner, "/services/test?rj_agent_preview=cs_0123456789abcdef")]: status, headers, body = get(client, path) assert status == 200 and "Public fixture" in body and "Preview content" not in body and "rj-agent-preview-banner" not in body, f"{label} must see the live page" assert "noindex" not in headers.get("x-robots-tag", ""), label assert get(anonymous, f"/news/agent-preview-news?rj_agent_preview={cs}")[0] == 404, "a visitor cannot see the hidden post through the preview" assert get(anonymous, f"/agent-new?rj_agent_preview={cs}")[0] == 404, "a visitor cannot see the new page through the preview" # A package that is no longer ready, or whose base changed, is not previewed page_file = root / "content/pages/en_services__test.json" original = page_file.read_bytes() page_file.write_text(page_file.read_text().replace("Public fixture", "Edited elsewhere")) status, _, body = get(owner, url) assert status == 200 and "Edited elsewhere" in body and "Preview content" not in body and "rj-agent-preview-banner" not in body, "a changed base falls back to the live page" page_file.write_bytes(original) assert "Preview content" in get(owner, url)[2], "preview works again once the base matches" php(f"raijuAgentChangesetDiscard('{cs}', ['userKey' => 'u_editor', 'isAdmin' => false]);") assert "Preview content" not in get(owner, url)[2], "a discarded package is not previewed" # Nothing was written by any of this assert content_hashes() == before, "previews must not change content" assert not (root / "content/analytics").exists() or not list((root / "content/analytics").rglob("*.json")), "previews are not visits" print("PASS preview: owner-only overlay on pages, hidden news and new pages, no editor, noindex/no-store, banner; others and stale packages see live content; nothing written") finally: site.close()