Take the website's screenshots with one command

composer screenshots runs Pest browser tests in tests/Screenshots, outside
every test suite: fixed demo data under a frozen clock, desktop (MacBook
14, 2x) and phone (iPhone 15 Pro, 3x) in light and dark, each capture
published at once as WebP at two widths into website/img/screenshots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 12:30:53 +02:00
co-authored by Claude Opus 5
parent 4470c2ed87
commit e49bdf3eb3
51 changed files with 607 additions and 1 deletions
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace Tests\Screenshots;
use App\Models\Share;
use App\Models\User;
use App\Services\ShareService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* The shares and the admin every screenshot shows. Names, sizes, tokens, counts and dates are fixed
* (with the clock frozen by the caller), so a run only produces different images when the interface
* changed. Files go through ShareService, so they exist encrypted on the faked `shares` disk.
*/
final class DemoData
{
/** The share a customer receives: link, QR code and download page. */
public const DELIVERY_TOKEN = 'hG7kP2mXq9LrT4vB';
/** The password-protected share the phone's password prompt belongs to. */
public const PROTECTED_TOKEN = 'Wn3sD8fJ5cYa1ZeQ';
public const PROTECTED_PASSWORD = 'autumn-harbour-42';
public static function admin(): User
{
return User::factory()->admin()->create([
'name' => 'Alex Morgan',
'email' => 'alex.morgan@example.com',
]);
}
/**
* Eight shares of different ages, sizes and states: one expired, two password-protected, some
* with a download limit, some never expiring.
*/
public static function shares(): void
{
$shares = [
['token' => self::DELIVERY_TOKEN, 'daysAgo' => 0, 'expiresAfterDays' => 7, 'downloads' => 3, 'options' => [],
'files' => ['Q3 Report.pdf' => 2400, 'Contract 2026.pdf' => 380, 'Product photos/hero-shot.jpg' => 4800, 'Product photos/detail.jpg' => 3900]],
['token' => self::PROTECTED_TOKEN, 'daysAgo' => 1, 'expiresAfterDays' => 3, 'downloads' => 1, 'options' => ['password' => self::PROTECTED_PASSWORD, 'max_downloads' => 3],
'files' => ['Payroll September.xlsx' => 210, 'Tax statement 2025.pdf' => 640]],
['token' => 'Rt5uV6wX7yZ8aB9c', 'daysAgo' => 2, 'expiresAfterDays' => 30, 'downloads' => 7, 'options' => ['max_downloads' => 10],
'files' => ['Brand guidelines.pdf' => 8600]],
['token' => 'Kc2dE3fG4hJ5kL6m', 'daysAgo' => 5, 'expiresAfterDays' => 14, 'downloads' => 2, 'options' => [],
'files' => ['Site plan rev C.dwg' => 12400, 'Site plan rev C.pdf' => 3100]],
['token' => 'Np7qR8sT9uV1wX2y', 'daysAgo' => 9, 'expiresAfterDays' => null, 'downloads' => 0, 'options' => ['password' => 'demo-interview'],
'files' => ['Interview recording.m4a' => 18300]],
['token' => 'Zb4cD5eF6gH7jK8L', 'daysAgo' => 14, 'expiresAfterDays' => 30, 'downloads' => 0, 'options' => ['max_downloads' => 1],
'files' => ['Invoice 2026-0412.pdf' => 95]],
['token' => 'Mq1rS2tU3vW4xY5z', 'daysAgo' => 21, 'expiresAfterDays' => null, 'downloads' => 4, 'options' => [],
'files' => ['Onboarding/Welcome.pdf' => 520, 'Onboarding/Handbook.pdf' => 2900, 'Onboarding/IT checklist.docx' => 130]],
['token' => 'Ae6fG7hJ8kL9mN1p', 'daysAgo' => 40, 'expiresAfterDays' => 10, 'downloads' => 12, 'options' => [],
'files' => ['Event photos.zip' => 15700]],
];
foreach ($shares as $share) {
self::share(...$share);
}
}
/**
* @param array{password?: string, max_downloads?: int} $options
* @param array<string, int> $files relative path => size in kilobytes
*/
private static function share(string $token, int $daysAgo, ?int $expiresAfterDays, int $downloads, array $options, array $files): void
{
$createdAt = Carbon::now()->subDays($daysAgo)->subHours(2);
Str::createRandomStringsUsing(fn (): string => $token);
try {
$share = app(ShareService::class)->createShare(
collect($files)->map(fn (int $kilobytes, string $path): array => [
'file' => UploadedFile::fake()->create(basename($path), $kilobytes),
'relativePath' => str_contains($path, '/') ? $path : null,
])->values()->all(),
[...$options, 'expires_at' => $expiresAfterDays === null ? null : $createdAt->copy()->addDays($expiresAfterDays)],
);
} finally {
Str::createRandomStringsNormally();
}
$share->forceFill(['download_count' => $downloads, 'created_at' => $createdAt, 'updated_at' => $createdAt])->save();
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace Tests\Screenshots;
use GdImage;
use RuntimeException;
/**
* Turns a raw browser capture into the website's images: WebP at each width the site's `srcset`
* asks for, under `<device>/<theme>/<name>-<width>.webp`. The capture is read right after it is
* taken, because Pest empties its screenshot directory when a browser run starts.
*/
final class Publisher
{
public function __construct(
private string $capturesPath,
private string $publishPath,
private int $quality = 82,
) {}
/**
* The publisher for this project: Pest's captures into `website/img/screenshots`.
*/
public static function forProject(): self
{
return new self(base_path('tests/Browser/Screenshots'), base_path('website/img/screenshots'));
}
/**
* Write the capture as WebP at each width, keeping its aspect ratio.
*
* @param list<int> $widths
*/
public function publish(string $capture, string $device, string $theme, string $name, array $widths): void
{
$source = $this->capturesPath.'/'.$capture.'.png';
if (! is_file($source) || ! ($image = @imagecreatefrompng($source)) instanceof GdImage) {
throw new RuntimeException("The capture [{$capture}] was not written, so [{$device}/{$theme}/{$name}] cannot be published.");
}
$directory = $this->publishPath.'/'.$device.'/'.$theme;
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
foreach ($widths as $width) {
$height = (int) round(imagesy($image) * $width / imagesx($image));
$resized = imagecreatetruecolor($width, $height);
imagecopyresampled($resized, $image, 0, 0, 0, 0, $width, $height, imagesx($image), imagesy($image));
imagewebp($resized, $directory.'/'.$name.'-'.$width.'.webp', $this->quality);
}
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
use App\Services\QrCodeService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Tests\Screenshots\DemoData;
use Tests\Screenshots\Publisher;
/*
* The screenshots on the website and in the README, from fixed demo data with the clock frozen.
* Not part of any test suite: `composer screenshots` runs this directory (Chromium), and each capture
* is published to website/img/screenshots as soon as it is taken.
*/
beforeEach(function () {
// Sessions have to outlive a request: a sign-in, an unlocked share.
config(['session.driver' => 'file']);
Storage::fake('shares');
Storage::fake('tmp-for-tests');
$this->travelTo(Carbon::parse('2026-10-01 09:30'));
// Livewire deletes temporary uploads a day older than now, by the files' real modification
// times: under the frozen clock that is every file this run stores.
config(['livewire.temporary_file_upload.cleanup' => false]);
DemoData::shares();
});
/**
* A visited page on a device in a theme, once it can be used. Tests call `visit()` themselves, on a
* line of its own: Pest starts its browser only for tests under tests/Browser or whose body calls
* `visit(` after whitespace.
*/
function shotPage(mixed $visit, string $device, string $theme): mixed
{
$pending = $device === 'desktop' ? $visit->on()->macbook14() : $visit->on()->iPhone15Pro();
$page = $theme === 'dark' ? $pending->inDarkMode() : $pending->inLightMode();
return $page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
/**
* Capture the viewport once fonts are in and every finite animation has run, then publish it.
*
* The in-process server listens on 127.0.0.1 at a random port, so links would read differently on
* every run: the page is shown as it reads on an installation at https://files.example.com, the
* server's origin replaced in text and fields, and the QR code drawn for that address.
*/
function shoot(mixed $page, string $device, string $theme, string $name): void
{
$origin = 'https://files.example.com';
$qrCode = app(QrCodeService::class)->svg($origin.'/s/'.DemoData::DELIVERY_TOKEN);
$page->script("document.head.insertAdjacentHTML('beforeend', '<style>*{caret-color:transparent!important}</style>')");
$page->script('(() => { const from = location.origin, to = '.json_encode($origin).'; document.querySelectorAll("input").forEach((input) => { input.value = input.value.replaceAll(from, to) }); const text = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); while (text.nextNode()) { text.currentNode.nodeValue = text.currentNode.nodeValue.replaceAll(from, to) } document.querySelectorAll("[data-qr-code]").forEach((panel) => { panel.innerHTML = '.json_encode($qrCode).' }) })()');
// Counting figures run on requestAnimationFrame rather than as Web Animations.
$page->wait(1.2)
->assertScript("document.fonts.status === 'loaded' && document.getAnimations().every((animation) => animation.playState !== 'running' || animation.effect?.getComputedTiming().iterations === Infinity)");
$capture = "{$device}-{$theme}-{$name}";
$page->screenshot(fullPage: false, filename: $capture);
Publisher::forProject()->publish($capture, $device, $theme, $name, $device === 'desktop' ? [1600, 800] : [1080, 540]);
}
/**
* Put files into the uploader the way a finished upload does: stored as Livewire temporary uploads
* and handed to `_finishUpload` by their signed names. A browser test cannot select files through
* the file input, because the in-process server does not store a multipart upload.
*
* @param array<string, int> $files relative path => size in kilobytes
*/
function selectFiles(mixed $page, array $files): void
{
$signed = [];
foreach ($files as $path => $kilobytes) {
// Livewire's own storage for an arriving upload: the file and its name, type and size beside it.
$stored = FileUploadConfiguration::storeTemporaryFile(UploadedFile::fake()->create(basename($path), $kilobytes), 'tmp-for-tests');
$signed[] = TemporaryUploadedFile::signPath(basename($stored));
}
$relativePaths = array_map(fn (string $path): ?string => str_contains($path, '/') ? $path : null, array_keys($files));
$page->script('(() => { const wire = Livewire.find(document.querySelector("[data-test=drop-zone]").closest("[wire\\\\:id]").getAttribute("wire:id")); wire.$set("relativePaths", '.json_encode($relativePaths).', false); wire._finishUpload("files", '.json_encode($signed).', true) })()');
$page->assertSee('Selected Files ('.count($files).')');
}
$files = ['Q3 Report.pdf' => 2400, 'Contract 2026.pdf' => 380, 'Product photos/hero-shot.jpg' => 4800, 'Product photos/detail.jpg' => 3900];
test('desktop', function (string $theme) use ($files) {
$upload = visit('/upload');
$page = shotPage($upload, 'desktop', $theme);
selectFiles($page, $files);
$page->click('label:has-text("Password protect")')
->type('input[wire\:model="password"]', DemoData::PROTECTED_PASSWORD)
->type('input[wire\:model="maxDownloads"]', '5');
// The drop zone, the files and the options fill the window; typing left the page wherever it scrolled.
$page->script("document.activeElement?.blur(); window.scrollTo(0, document.querySelector('[data-test=drop-zone]').getBoundingClientRect().top + window.scrollY - 24)");
shoot($page, 'desktop', $theme, '01-upload');
$created = visit(route('share.created', DemoData::DELIVERY_TOKEN, false));
$page = shotPage($created, 'desktop', $theme);
shoot($page, 'desktop', $theme, '02-share-created');
$page->click('[data-test="show-qr-code"]')
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
shoot($page, 'desktop', $theme, '03-qr-code');
$download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false));
shoot(shotPage($download, 'desktop', $theme), 'desktop', $theme, '04-download');
$this->actingAs(DemoData::admin());
$dashboard = visit('/admin/dashboard');
shoot(shotPage($dashboard, 'desktop', $theme), 'desktop', $theme, '05-admin-dashboard');
// Branding typed in but not saved, so the other shots keep SealShare's own.
$settings = visit('/admin/settings');
$page = shotPage($settings, 'desktop', $theme)
->type('input[wire\\:model="siteTitle"]', 'Acme Files')
->type('textarea[wire\\:model="siteDescription"]', 'Send and receive files securely with Acme Engineering.');
$page->script('document.activeElement?.blur(); window.scrollTo(0, 0)');
shoot($page, 'desktop', $theme, '06-admin-settings');
})->with(['light', 'dark']);
test('phone', function (string $theme) use ($files) {
$upload = visit('/upload');
$page = shotPage($upload, 'phone', $theme);
selectFiles($page, $files);
shoot($page, 'phone', $theme, '01-upload');
$password = visit(route('share.download', DemoData::PROTECTED_TOKEN, false));
shoot(shotPage($password, 'phone', $theme), 'phone', $theme, '02-password');
$download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false));
shoot(shotPage($download, 'phone', $theme), 'phone', $theme, '03-download');
$created = visit(route('share.created', DemoData::DELIVERY_TOKEN, false));
$page = shotPage($created, 'phone', $theme);
$page->click('[data-test="show-qr-code"]')
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
shoot($page, 'phone', $theme, '04-qr-code');
})->with(['light', 'dark']);