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');
mkdir($site . '/assets/images', 0755, true);
mkdir($site . '/content/news', 0755, true);
copy($root . '/assets/images/placeholder-image.webp', $site . '/assets/images/library.webp');
file_put_contents($site . '/content/events.json', json_encode(['events' => []]));
file_put_contents($site . '/content/ai-settings.json', json_encode(['enabled' => true, 'agent' => ['enabled' => true]]));
define('SITE_LANG_DEFAULT', 'en');
define('CONTENT_BASE_PATH', $site . '/content/pages/');
$_SESSION = ['admin_role' => 'editor', 'admin_user_id' => 'u_editor', 'admin_username' => 'editor'];
require_once $site . '/includes/ai/agent/tools.php';
// ---- Test images -------------------------------------------------------------------------------
function mediaGd(int $w, int $h, bool $alpha = false): string {
$image = imagecreatetruecolor($w, $h);
imagealphablending($image, false);
imagesavealpha($image, true);
imagefill($image, 0, 0, $alpha ? imagecolorallocatealpha($image, 200, 30, 30, 64) : imagecolorallocate($image, 30, 120, 200));
imagefilledrectangle($image, 0, 0, intdiv($w, 2), intdiv($h, 2), imagecolorallocate($image, 250, 250, 20)); // top-left marker
ob_start();
$alpha ? imagepng($image) : imagejpeg($image, null, 90);
return (string)ob_get_clean();
}
/** Insert JPEG segments right after the SOI marker. */
function mediaJpegWith(string $jpeg, array $segments): string {
$out = '';
foreach ($segments as [$marker, $payload]) $out .= "\xFF" . chr($marker) . pack('n', strlen($payload) + 2) . $payload;
return substr($jpeg, 0, 2) . $out . substr($jpeg, 2);
}
function mediaExif(int $orientation): string {
$tiff = "MM\x00\x2A\x00\x00\x00\x08" . "\x00\x01" . "\x01\x12\x00\x03\x00\x00\x00\x01" . pack('n', $orientation) . "\x00\x00" . "\x00\x00\x00\x00";
return "Exif\0\0" . $tiff;
}
function mediaTmp(string $bytes, string $ext = 'jpg'): string {
$file = tempnam(sys_get_temp_dir(), 'media-test-') . '.' . $ext;
file_put_contents($file, $bytes);
register_shutdown_function(static fn() => @unlink($file));
return $file;
}
function mediaRejects(string $file, string $needle, string $what, array $options = []): void {
try {
raijuAgentIngestImage($file, $options);
} catch (RuntimeException $e) {
mediaAssert(stripos($e->getMessage(), $needle) !== false, "{$what}: message '{$e->getMessage()}' should mention '{$needle}'");
return;
}
fwrite(STDERR, "FAIL: {$what} must be rejected\n");
exit(1);
}
mediaAssert(raijuAgentImageSupport()['gd'], 'GD is available for these tests');
// ---- What is refused -------------------------------------------------------------------------------
$png = (static function () { $i = imagecreatetruecolor(4, 4); ob_start(); imagepng($i); return (string)ob_get_clean(); })();
mediaRejects(mediaTmp('', 'jpg'), 'empty', 'an empty file');
mediaRejects(mediaTmp('', 'jpg'), 'Only JPEG, PNG and WebP', 'PHP code named .jpg');
mediaRejects(mediaTmp('', 'png'), 'Only JPEG, PNG and WebP', 'an SVG named .png');
mediaRejects(mediaTmp("GIF89a\x01\x00\x01\x00\x00\x00\x00;", 'jpg'), 'Only JPEG, PNG and WebP', 'a GIF with PHP behind it');
mediaRejects(mediaTmp($png, 'png'), 'too large', 'a file above the limit', ['maxBytes' => 50]);
// An image that claims 13,000 x 5 pixels, and one with 50 megapixels: refused from the header, before any decoding
$hugeHeader = static function (int $w, int $h): string {
$ihdr = pack('NN', $w, $h) . "\x08\x02\x00\x00\x00";
$chunk = static fn(string $type, string $data) => pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data));
return "\x89PNG\r\n\x1a\n" . $chunk('IHDR', $ihdr) . $chunk('IDAT', gzcompress(str_repeat("\0", 32))) . $chunk('IEND', '');
};
mediaRejects(mediaTmp($hugeHeader(13000, 5), 'png'), 'too many pixels', 'one side above 12,000 px');
mediaRejects(mediaTmp($hugeHeader(8000, 6500), 'png'), 'too many pixels', 'more than 40 megapixels');
mediaRejects(mediaTmp(substr($png, 0, 40), 'png'), 'decoded', 'a truncated image');
// ---- What is kept, what is removed -------------------------------------------------------------------------
$plain = mediaGd(60, 30);
$clean = raijuAgentIngestImage(mediaTmp($plain));
mediaAssert($clean['ext'] === 'webp' && $clean['mime'] === 'image/webp' && substr($clean['binary'], 8, 4) === 'WEBP' && $clean['width'] === 60 && $clean['height'] === 30 && $clean['ai'] === '', 're-encoded as WebP, size kept, no AI label');
$exifJpeg = mediaJpegWith($plain, [
[0xE1, mediaExif(6)],
[0xFE, 'GPSSECRET-48.2082N-16.3738E and a comment'],
[0xE1, "http://ns.adobe.com/xap/1.0/\0" . '
Body
', 'imageRef' => $stock['ref']]); mediaAssert(!empty($r['ok']), 'news with a stock image'); mediaAssert(str_ends_with(raijuAgentChangesetLoad($id2)['ops'][2]['post']['content'], 'Photo: Jane Doe / Pexels
'), 'news get the credit as a closing line'); $r = raijuAgentChangesetStage($id2, ['type' => 'create_event', 'lang' => 'en', 'title' => 'Robot party', 'date' => '2026-11-10', 'time' => '19:00', 'location' => 'Lab']); $r = raijuAgentChangesetStage($id2, ['type' => 'create_page', 'lang' => 'en', 'slug' => 'robots', 'title' => 'Robots', 'sections' => [['type' => 'image', 'fields' => ['src' => $generated['ref'], 'alt' => 'A friendly robot']]]]); mediaAssert(!empty($r['ok']), 'page with a generated image: ' . json_encode($r)); $ready = raijuAgentChangesetFinalize($id2); mediaAssert($ready['status'] === 'ready', 'package with staged images is ready: ' . json_encode($ready['validation']) . json_encode($ready['renderCheck'])); // ---- Apply moves what is used and nothing else --------------------------------------------------------------------------- $stockFile = $site . raijuAgentChangesetLoad($id2)['media'][$stock['ref']]['finalPath']; $actor = ['userKey' => 'u_editor', 'isAdmin' => false]; $blocker = $site . raijuAgentChangesetLoad($id2)['media'][$generated['ref']]['finalPath']; mkdir(dirname($blocker), 0755, true); file_put_contents($blocker, 'someone put a file here meanwhile'); $before = array_map(fn($f) => hash_file('sha256', $f), glob($site . '/content/pages/*.json')); $res = raijuAgentChangesetApply($id2, $ready['previewHash'], $actor); mediaAssert(!$res['ok'] && str_contains($res['error'], 'already exists in the library') && !is_file($stockFile) && is_file($site . '/' . raijuAgentChangesetLoad($id2)['media'][$stock['ref']]['stagingFile']), 'a name clash fails the apply and puts moved images back: ' . json_encode($res)); mediaAssert(array_map(fn($f) => hash_file('sha256', $f), glob($site . '/content/pages/*.json')) === $before && raijuMediaAiLabelFor('image', 'agent/' . basename($stockFile)) === '', 'no content, no metadata left behind'); // A failed apply is final; build the same package again without the clash unlink($blocker); $cs3 = $cs2; // the failed change set stays failed: stage again from scratch $id3 = raijuAgentChangesetCreate(['owner' => 'u_editor', 'runId' => 'run_1111111111111111'])['id']; $stock3 = raijuAgentMediaRegister($id3, $clean, ['source' => 'stock', 'name' => 'Mountain lake', 'credit' => ['source' => 'pexels', 'author' => 'Jane Doe', 'authorUrl' => 'https://www.pexels.com/@jane', 'sourceUrl' => 'https://www.pexels.com/photo/1', 'license' => 'Pexels License', 'attribution' => 'Photo by Jane Doe on Pexels']]); $unused3 = raijuAgentMediaRegister($id3, $clean, ['source' => 'stock', 'name' => 'Never used']); $gen3 = raijuAgentMediaRegister($id3, $clean, ['source' => 'generated', 'name' => 'A friendly robot', 'ai' => 'generated']); $up3 = raijuAgentMediaRegister($id3, $rotated, ['source' => 'upload', 'name' => 'office.jpg', 'ai' => 'generated']); raijuAgentChangesetStage($id3, ['type' => 'set_image', 'target' => 'page:en_scan-standard', 'path' => 'sections.2.src', 'mediaRef' => $stock3['ref'], 'alt' => 'A lake']); raijuAgentChangesetStage($id3, ['type' => 'create_page', 'lang' => 'en', 'slug' => 'robots', 'title' => 'Robots', 'sections' => [['type' => 'image', 'fields' => ['src' => $gen3['ref'], 'alt' => 'A friendly robot']], ['type' => 'card', 'fields' => ['title' => 'Office', 'image' => $up3['ref']]]]]); $ready3 = raijuAgentChangesetFinalize($id3); mediaAssert($ready3['status'] === 'ready', 'second package ready: ' . json_encode($ready3)); $media3 = raijuAgentChangesetLoad($id3)['media']; $res = raijuAgentChangesetApply($id3, $ready3['previewHash'], $actor); mediaAssert($res['ok'], 'apply with images: ' . json_encode($res)); $stockPath = $site . $media3[$stock3['ref']]['finalPath']; mediaAssert(is_file($stockPath) && is_file($site . $media3[$gen3['ref']]['finalPath']) && is_file($site . $media3[$up3['ref']]['finalPath']), 'the images the package uses are in the library'); mediaAssert(!is_file($site . $media3[$unused3['ref']]['finalPath']) && !is_dir($site . '/content/ai-agent/staging/' . $id3), 'the unused image never reached the library, and the staging folder is gone'); $stockName = substr($media3[$stock3['ref']]['finalPath'], strlen('/assets/images/')); $credit = raijuMediaCreditFor('image', $stockName); mediaAssert($credit['author'] === 'Jane Doe' && $credit['source'] === 'pexels' && $credit['license'] === 'Pexels License' && $credit['attribution'] === 'Photo by Jane Doe on Pexels' && $credit['importedBy'] === 'u_editor' && isset($credit['importedAt']), 'credit stored in media-meta: ' . json_encode($credit)); mediaAssert(raijuMediaAiLabelFor('image', substr($media3[$gen3['ref']]['finalPath'], 15)) === 'generated' && raijuMediaAiLabelFor('image', substr($media3[$up3['ref']]['finalPath'], 15)) === 'generated' && raijuMediaAiLabelFor('image', $stockName) === '', 'AI label for the generated image and for the upload whose metadata said so, none for the photo'); $page = json_decode((string)file_get_contents($site . '/content/pages/en_scan-standard.json'), true); mediaAssert($page['sections'][2]['src'] === $media3[$stock3['ref']]['finalPath'] && $page['sections'][2]['caption'] === 'Cap – Photo: Jane Doe / Pexels', 'the page uses the library path; the credit was added to the existing caption: ' . $page['sections'][2]['caption']); mediaAssert(raijuAgentMediaReferenced($media3[$stock3['ref']]['finalPath']) && !raijuAgentMediaReferenced('/assets/images/agent/nothing.webp'), 'reference check'); // ---- Undo takes unused images out of the library -------------------------------------------------------------------------------- $undo = raijuAgentChangesetUndo($id3, raijuAgentUndoToken($id3), $actor); mediaAssert($undo['ok'] && !is_file($stockPath) && !is_file($site . $media3[$gen3['ref']]['finalPath']), 'undo moves the package\'s images out of the library: ' . json_encode($undo)); mediaAssert(is_file($site . '/assets/images-trash/' . $stockName) && raijuMediaCreditFor('image-trash', $stockName)['author'] === 'Jane Doe' && raijuMediaCreditFor('image', $stockName) === [], 'they are in the media trash, credit travelled with them'); // ---- Image still in use elsewhere stays ---------------------------------------------------------------------------------------------- $id4 = raijuAgentChangesetCreate(['owner' => 'u_editor', 'runId' => 'run_2222222222222222'])['id']; $m4 = raijuAgentMediaRegister($id4, $clean, ['source' => 'upload', 'name' => 'shared.jpg']); raijuAgentChangesetStage($id4, ['type' => 'set_image', 'target' => 'page:en_scan-standard', 'path' => 'sections.2.src', 'mediaRef' => $m4['ref'], 'alt' => 'Shared']); $r4 = raijuAgentChangesetFinalize($id4); $final4 = raijuAgentChangesetLoad($id4)['media'][$m4['ref']]['finalPath']; mediaAssert(raijuAgentChangesetApply($id4, $r4['previewHash'], $actor)['ok'], 'apply'); file_put_contents($site . '/content/pages/en_other.json', json_encode(['page' => 'en_other', 'lang' => 'en', 'title' => 'Other', 'sections' => [['id' => 'x', 'type' => 'image', 'src' => $final4, 'alt' => 'also used here']]])); $undo4 = raijuAgentChangesetUndo($id4, raijuAgentUndoToken($id4), $actor); mediaAssert($undo4['ok'] && is_file($site . $final4), 'an image that other content uses stays in the library after undo: ' . json_encode($undo4) . ' ' . $final4); // ---- Cleanup: discarded packages leave no images behind ------------------------------------------------------------------------------------ $id5 = raijuAgentChangesetCreate(['owner' => 'u_editor', 'runId' => 'run_3333333333333333'])['id']; raijuAgentMediaRegister($id5, $clean, ['source' => 'upload', 'name' => 'orphan.jpg']); mediaAssert(count(glob($site . '/content/ai-agent/staging/' . $id5 . '/*')) === 2, 'image and sidecar staged'); mediaAssert(raijuAgentChangesetDiscard($id5, $actor) && !is_dir($site . '/content/ai-agent/staging/' . $id5), 'discarding removes the staged images'); mediaAssert(!glob($site . '/assets/images/agent/orphan*'), 'and nothing of it is in the library'); // ---- Stock settings ---------------------------------------------------------------------------------------------------------------------------- mediaAssert(raijuAgentStockSettings()['available'] === false, 'no stock key, no stock photos'); file_put_contents($site . '/content/ai-settings.json', json_encode(['enabled' => true, 'agent' => ['enabled' => true], 'stock' => ['pexels' => ['apiKey' => ' key-123 ']]])); mediaAssert(raijuAgentStockSettings() === ['provider' => 'pexels', 'apiKey' => 'key-123', 'available' => true], 'the key is read and trimmed'); mediaAssert(in_array('search_stock_images', array_column(raijuAgentToolsForUser(), 'name'), true) && in_array('import_stock_image', array_column(raijuAgentToolsForUser(), 'name'), true), 'with a key the stock tools are offered'); mediaAssert(raijuAgentStockNet()['apiBase'] === 'https://api.pexels.com' && raijuAgentStockNet()['secure'] === true, 'the real endpoint is https and the Pexels host'); echo "PASS agent media: refusals, orientation, metadata and hidden code removed, provenance read first, staging, credits, apply moves only used images (rollback on clash), undo, cleanup\n";