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:
co-authored by
Claude Opus 5
parent
4470c2ed87
commit
e49bdf3eb3
+1
-1
@@ -21,7 +21,7 @@ pest()->extend(TestCase::class)
|
||||
// EnsureSetupComplete middleware redirects to /setup unless an admin exists.
|
||||
User::factory()->admin()->create(['email' => 'admin-setup@test.com']);
|
||||
})
|
||||
->in('Feature', 'Browser');
|
||||
->in('Feature', 'Browser', 'Screenshots');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Tests\Screenshots\Publisher;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->root = sys_get_temp_dir().'/sealshare-publisher-'.uniqid();
|
||||
mkdir($this->root.'/captures', 0755, true);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
exec('rm -rf '.escapeshellarg($this->root));
|
||||
});
|
||||
|
||||
test('a capture is published as WebP at each width, keeping its aspect ratio', function () {
|
||||
$capture = imagecreatetruecolor(3024, 1964);
|
||||
imagepng($capture, $this->root.'/captures/desktop-light-01-upload.png');
|
||||
|
||||
(new Publisher($this->root.'/captures', $this->root.'/site'))
|
||||
->publish('desktop-light-01-upload', 'desktop', 'light', '01-upload', [1600, 800]);
|
||||
|
||||
foreach ([1600 => 1039, 800 => 520] as $width => $height) {
|
||||
$path = $this->root."/site/desktop/light/01-upload-{$width}.webp";
|
||||
|
||||
expect($path)->toBeFile()
|
||||
->and(getimagesize($path))->toMatchArray([0 => $width, 1 => $height, 'mime' => 'image/webp']);
|
||||
}
|
||||
});
|
||||
|
||||
test('a capture that was not written fails the run', function () {
|
||||
(new Publisher($this->root.'/captures', $this->root.'/site'))
|
||||
->publish('phone-dark-02-password', 'phone', 'dark', '02-password', [1080]);
|
||||
})->throws(RuntimeException::class, 'The capture [phone-dark-02-password] was not written');
|
||||
Reference in New Issue
Block a user