From 2bd431f8391381711153082d17149ce3e7219cc7 Mon Sep 17 00:00:00 2001 From: Yuriy Gerasymov Date: Wed, 1 Apr 2026 21:08:58 -0700 Subject: [PATCH 1/5] ygerasimov/diffy-pm:#1531: Introduce screenshot:create-folder. --- src/Commands/ScreenshotCommand.php | 67 ++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/Commands/ScreenshotCommand.php b/src/Commands/ScreenshotCommand.php index 29a0a5f..beef07f 100644 --- a/src/Commands/ScreenshotCommand.php +++ b/src/Commands/ScreenshotCommand.php @@ -229,6 +229,73 @@ public function createScreenshotBaseline($projectId, $environment, array $option return new ResultData(); } + /** + * Create a functional test snapshot from a folder of screenshots + * + * @command screenshot:create-folder + * + * @param int $projectId ID of the project + * @param string $folderPath Path to the folder containing screenshot images (PNG/JPG) + * + * @usage screenshot:create-folder 342 ./screenshots Upload screenshots from folder as a functional test snapshot. + */ + public function createFolderScreenshot($projectId, string $folderPath) + { + $apiKey = Config::getConfig()['key']; + + Diffy::setApiKey($apiKey); + + if (!is_dir($folderPath)) { + $this->io()->write(sprintf('Folder not found: %s', $folderPath)); + throw new InvalidArgumentException(); + } + + $files = array_values(array_filter( + scandir($folderPath), + function ($file) use ($folderPath) { + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + return in_array($ext, ['png', 'jpg', 'jpeg']) && is_file($folderPath . DIRECTORY_SEPARATOR . $file); + } + )); + + if (empty($files)) { + $this->io()->write(sprintf('No PNG/JPG images found in folder: %s', $folderPath)); + throw new InvalidArgumentException(); + } + + $data = [ + ['name' => 'snapshotName', 'contents' => basename(realpath($folderPath))], + ['name' => 'functionalTest', 'contents' => '1'], + ]; + + foreach ($files as $key => $filename) { + $filepath = $folderPath . DIRECTORY_SEPARATOR . $filename; + $imageSize = getimagesize($filepath); + if ($imageSize === false) { + $this->io()->write(sprintf('Could not read image dimensions for file: %s', $filename)); + throw new InvalidArgumentException(); + } + $width = $imageSize[0]; + $url = '/' . pathinfo($filename, PATHINFO_FILENAME); + + $data[] = ['name' => 'breakpoints[' . $key . ']', 'contents' => (string) $width]; + $data[] = ['name' => 'urls[' . $key . ']', 'contents' => $url]; + $data[] = [ + 'Content-type' => 'multipart/form-data', + 'name' => 'files[' . $key . ']', + 'filename' => $filename, + 'contents' => file_get_contents($filepath), + ]; + } + + $screenshotId = Diffy::multipartRequest('POST', 'projects/' . $projectId . '/create-custom-snapshot', $data); + + $this->io()->write($screenshotId); + + // Successful exit. + return new ResultData(); + } + /** * Sets a new baseline from a screenshot ID. * From de3fb08b4811f3ab2817f4e8a545062844a149f5 Mon Sep 17 00:00:00 2001 From: Yuriy Gerasymov Date: Wed, 1 Apr 2026 21:09:32 -0700 Subject: [PATCH 2/5] ygerasimov/diffy-pm:#1531: Add ability to call APIs of any URL like staging or local Diffy instance. --- diffy | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/diffy b/diffy index d72cac6..5ea77b2 100755 --- a/diffy +++ b/diffy @@ -24,6 +24,25 @@ $classLoader = require $autoloaderPath; // Customization variables $argv = $_SERVER['argv']; + +// Handle --base-url option globally before passing argv to Robo. +foreach ($argv as $i => $arg) { + if (strpos($arg, '--base-url=') === 0) { + $baseUrl = rtrim(substr($arg, strlen('--base-url=')), '/') . '/'; + \Diffy\Diffy::$baseUrl = $baseUrl; + \Diffy\Diffy::$client = null; + unset($argv[$i]); + break; + } elseif ($arg === '--base-url' && isset($argv[$i + 1])) { + $baseUrl = rtrim($argv[$i + 1], '/') . '/'; + \Diffy\Diffy::$baseUrl = $baseUrl; + \Diffy\Diffy::$client = null; + unset($argv[$i], $argv[$i + 1]); + break; + } +} +$argv = array_values($argv); + $appName = "DiffyCli"; $appVersion = trim(file_get_contents(__DIR__ . '/VERSION')); $commandClasses = [ From 0358b844b6d2eea04f1ffc32b92f4ed293f40698 Mon Sep 17 00:00:00 2001 From: Yuriy Gerasymov Date: Mon, 6 Apr 2026 08:55:01 -0700 Subject: [PATCH 3/5] ygerasimov/diffy-pm:#1531: Only png / webp files. --- src/Commands/ScreenshotCommand.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Commands/ScreenshotCommand.php b/src/Commands/ScreenshotCommand.php index beef07f..d2b81da 100644 --- a/src/Commands/ScreenshotCommand.php +++ b/src/Commands/ScreenshotCommand.php @@ -254,12 +254,12 @@ public function createFolderScreenshot($projectId, string $folderPath) scandir($folderPath), function ($file) use ($folderPath) { $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); - return in_array($ext, ['png', 'jpg', 'jpeg']) && is_file($folderPath . DIRECTORY_SEPARATOR . $file); + return in_array($ext, ['png', 'webp']) && is_file($folderPath . DIRECTORY_SEPARATOR . $file); } )); if (empty($files)) { - $this->io()->write(sprintf('No PNG/JPG images found in folder: %s', $folderPath)); + $this->io()->write(sprintf('No PNG/WebP images found in folder: %s', $folderPath)); throw new InvalidArgumentException(); } From 45aced00eb9d98ed65041371cc991e429fd26ffa Mon Sep 17 00:00:00 2001 From: Yuriy Gerasymov Date: Mon, 6 Apr 2026 08:58:32 -0700 Subject: [PATCH 4/5] ygerasimov/diffy-pm:#1531: Make sure URL names are safe. --- src/Commands/ScreenshotCommand.php | 41 ++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/Commands/ScreenshotCommand.php b/src/Commands/ScreenshotCommand.php index d2b81da..3ff5e2a 100644 --- a/src/Commands/ScreenshotCommand.php +++ b/src/Commands/ScreenshotCommand.php @@ -250,40 +250,53 @@ public function createFolderScreenshot($projectId, string $folderPath) throw new InvalidArgumentException(); } - $files = array_values(array_filter( - scandir($folderPath), - function ($file) use ($folderPath) { - $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); - return in_array($ext, ['png', 'webp']) && is_file($folderPath . DIRECTORY_SEPARATOR . $file); + $basePath = realpath($folderPath); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($basePath, \RecursiveDirectoryIterator::SKIP_DOTS) + ); + + // Collect [filepath => url] sorted by relative path for deterministic ordering. + $found = []; + foreach ($iterator as $fileInfo) { + $ext = strtolower($fileInfo->getExtension()); + if (!in_array($ext, ['png', 'webp'])) { + continue; } - )); + $filepath = $fileInfo->getPathname(); + $relativePath = ltrim(substr($filepath, strlen($basePath)), DIRECTORY_SEPARATOR); + // Build URL: replace directory separators with "-", strip extension, make URL-safe. + $withoutExt = pathinfo(str_replace(DIRECTORY_SEPARATOR, '-', $relativePath), PATHINFO_FILENAME); + $urlSlug = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '-', $withoutExt)); + $found[$relativePath] = ['filepath' => $filepath, 'url' => '/' . $urlSlug]; + } + ksort($found); + $found = array_values($found); - if (empty($files)) { + if (empty($found)) { $this->io()->write(sprintf('No PNG/WebP images found in folder: %s', $folderPath)); throw new InvalidArgumentException(); } $data = [ - ['name' => 'snapshotName', 'contents' => basename(realpath($folderPath))], + ['name' => 'snapshotName', 'contents' => basename($basePath)], ['name' => 'functionalTest', 'contents' => '1'], ]; - foreach ($files as $key => $filename) { - $filepath = $folderPath . DIRECTORY_SEPARATOR . $filename; + foreach ($found as $key => $item) { + $filepath = $item['filepath']; $imageSize = getimagesize($filepath); if ($imageSize === false) { - $this->io()->write(sprintf('Could not read image dimensions for file: %s', $filename)); + $this->io()->write(sprintf('Could not read image dimensions for file: %s', $filepath)); throw new InvalidArgumentException(); } $width = $imageSize[0]; - $url = '/' . pathinfo($filename, PATHINFO_FILENAME); $data[] = ['name' => 'breakpoints[' . $key . ']', 'contents' => (string) $width]; - $data[] = ['name' => 'urls[' . $key . ']', 'contents' => $url]; + $data[] = ['name' => 'urls[' . $key . ']', 'contents' => $item['url']]; $data[] = [ 'Content-type' => 'multipart/form-data', 'name' => 'files[' . $key . ']', - 'filename' => $filename, + 'filename' => basename($filepath), 'contents' => file_get_contents($filepath), ]; } From 75c79b7f59c251bb27873f479657af9d26122c02 Mon Sep 17 00:00:00 2001 From: Yuriy Gerasymov Date: Mon, 6 Apr 2026 09:45:09 -0700 Subject: [PATCH 5/5] ygerasimov/diffy-pm:#1531: Upload images with batches 10 images each. --- src/Commands/ScreenshotCommand.php | 63 ++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/src/Commands/ScreenshotCommand.php b/src/Commands/ScreenshotCommand.php index 3ff5e2a..197c742 100644 --- a/src/Commands/ScreenshotCommand.php +++ b/src/Commands/ScreenshotCommand.php @@ -13,6 +13,7 @@ class ScreenshotCommand extends Tasks { + const UPLOAD_BATCH_SIZE = 10; /** * Create a screenshot from environment * @@ -277,31 +278,51 @@ public function createFolderScreenshot($projectId, string $folderPath) throw new InvalidArgumentException(); } - $data = [ - ['name' => 'snapshotName', 'contents' => basename($basePath)], - ['name' => 'functionalTest', 'contents' => '1'], - ]; + $batches = array_chunk($found, self::UPLOAD_BATCH_SIZE); - foreach ($found as $key => $item) { - $filepath = $item['filepath']; - $imageSize = getimagesize($filepath); - if ($imageSize === false) { - $this->io()->write(sprintf('Could not read image dimensions for file: %s', $filepath)); - throw new InvalidArgumentException(); - } - $width = $imageSize[0]; - - $data[] = ['name' => 'breakpoints[' . $key . ']', 'contents' => (string) $width]; - $data[] = ['name' => 'urls[' . $key . ']', 'contents' => $item['url']]; - $data[] = [ - 'Content-type' => 'multipart/form-data', - 'name' => 'files[' . $key . ']', - 'filename' => basename($filepath), - 'contents' => file_get_contents($filepath), + $progressBar = $this->io()->createProgressBar(count($found)); + $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%%'); + $progressBar->start(); + + $screenshotId = null; + foreach ($batches as $batchIndex => $batch) { + $data = [ + ['name' => 'snapshotName', 'contents' => basename($basePath)], + ['name' => 'functionalTest', 'contents' => '1'], ]; + + foreach ($batch as $key => $item) { + $filepath = $item['filepath']; + $imageSize = getimagesize($filepath); + if ($imageSize === false) { + $progressBar->finish(); + $this->io()->newLine(); + $this->io()->write(sprintf('Could not read image dimensions for file: %s', $filepath)); + throw new InvalidArgumentException(); + } + $width = $imageSize[0]; + + $data[] = ['name' => 'breakpoints[' . $key . ']', 'contents' => (string) $width]; + $data[] = ['name' => 'urls[' . $key . ']', 'contents' => $item['url']]; + $data[] = [ + 'Content-type' => 'multipart/form-data', + 'name' => 'files[' . $key . ']', + 'filename' => basename($filepath), + 'contents' => file_get_contents($filepath), + ]; + } + + if ($batchIndex === 0) { + $screenshotId = Diffy::multipartRequest('POST', 'projects/' . $projectId . '/create-custom-snapshot', $data); + } else { + Diffy::multipartRequest('POST', 'snapshots/' . $screenshotId, $data); + } + + $progressBar->advance(count($batch)); } - $screenshotId = Diffy::multipartRequest('POST', 'projects/' . $projectId . '/create-custom-snapshot', $data); + $progressBar->finish(); + $this->io()->newLine(); $this->io()->write($screenshotId);