Files
SealShare/tests/Screenshots/ScreenshotsTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 e833647768 Show the password generator in the website's screenshots
The desktop upload shot fills the password with Generate, and the share
created shot now comes from creating that share, so the password is
offered beside the link. The created share takes a fixed token, so its
link and QR code read the same on every run, and is deleted before the
dashboard shot, which no longer counts the unfinished upload in its disk
usage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:33:26 +02:00

176 lines
8.5 KiB
PHP

<?php
use App\Models\Share;
use App\Services\QrCodeService;
use App\Services\ShareService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
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');
$this->travelTo(Carbon::parse('2026-10-01 09:30'));
DemoData::shares();
});
afterEach(function () {
Str::createRandomStringsNormally();
});
/**
* 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, string $token = DemoData::DELIVERY_TOKEN): void
{
$origin = 'https://files.example.com';
$qrCode = app(QrCodeService::class)->svg($origin.'/s/'.$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: registered through the page's own
* `registerFiles`, their encrypted content stored here as the browser would have sent it (zeros of
* the demo size), and the list refreshed to show them uploaded.
*
* @param array<string, int> $files relative path => size in kilobytes
*/
function selectFiles(mixed $page, array $files): void
{
$selection = collect($files)->map(fn (int $kilobytes, string $path): array => [
'name' => basename($path),
'size' => $kilobytes * 1024,
'path' => str_contains($path, '/') ? $path : null,
])->values();
$wire = 'Livewire.find(document.querySelector("[data-test=drop-zone]").closest("[wire\\\\:id]").getAttribute("wire:id"))';
$page->script('(async () => { await '.$wire.'.registerFiles('.json_encode($selection).') })()');
$page->waitForText('Selected Files ('.count($files).')');
$shareService = app(ShareService::class);
foreach (Share::query()->whereNull('completed_at')->latest('id')->firstOrFail()->files as $file) {
$header = $shareService->readHeader($file);
for ($index = 0; $index < $header['chunkCount']; $index++) {
$length = min($header['chunkSize'], $file->file_size - $index * $header['chunkSize']);
$shareService->storeChunk($file->refresh(), $index, encryptedChunk($file, str_repeat("\0", $length), $index, $index === $header['chunkCount'] - 1));
}
}
$page->script('(async () => { await '.$wire.'.$refresh() })()');
$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) {
// The share this upload creates takes a fixed token (share tokens are the only 16-character random
// strings); sessions and everything else stay random.
Str::createRandomStringsUsing(fn (int $length): string => $length === 16
? DemoData::CREATED_TOKEN
: substr(bin2hex(random_bytes($length)), 0, $length));
$upload = visit('/upload');
$page = shotPage($upload, 'desktop', $theme);
selectFiles($page, $files);
$page->click('label:has-text("Password protect")')
->click('[data-test="generate-password"]')
->wait(1)
->assertScript("document.querySelector('input[autocomplete=\"new-password\"]').value.length > 0");
$page->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');
// Creating the share is what offers the password once more beside the link.
$page->click('[data-test="create-share"]')
->waitForText('Share Created!');
$page->waitForEvent('networkidle')
->assertScript("document.querySelector('[data-test=\"share-password\"]') !== null");
shoot($page, 'desktop', $theme, '02-share-created', DemoData::CREATED_TOKEN);
$page->click('[data-test="show-qr-code"]')
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
shoot($page, 'desktop', $theme, '03-qr-code', DemoData::CREATED_TOKEN);
// The dashboard shows the eight demo shares, as before.
app(ShareService::class)->deleteShare(Share::query()->where('token', DemoData::CREATED_TOKEN)->firstOrFail());
$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']);