Files
SealShare/tests/Screenshots/ScreenshotsTest.php
T
Andreas Reinhold / reiniandClaude Opus 5.5 368a3b8cb5
docker / test (8.5) (push) Successful in 3m25s
linter / quality (push) Successful in 50s
tests / ci (8.5) (push) Successful in 3m24s
docker / build-and-push (push) Successful in 9m41s
docker / release (push) Skipped
Describe SealShare 2.4 on the website
A new "Download into a folder" feature card describes the folder
download for large shares: the files saved one by one into a folder the
recipient picks, in their folders, resuming after a broken connection,
in Chrome and Edge on a computer. How it works, Files and folders
(Browse Folder), Download limits (the hour starts again with every
download) and ZIP download (progress and size) say what 2.4 changed,
and the download screenshots' captions and alt texts match the page.

Every screenshot is retaken: the upload page shows Browse Folder, the
download page the file count, the total size and the folder download.
The phone shots switch the folder download off, as real phones have no
folder picker and desktop Chromium would otherwise show it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:38:42 +02:00

189 lines
9.4 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);
// Creating the share sends this text through the page's own upload before the share is created.
$page->type('[data-test="share-text"]', 'The signed contract is in the folder. Call me on +41 44 555 01 23 if anything is missing.');
$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 foot of the drop zone, the files, the text and the password 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().bottom + window.scrollY - 80)");
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 nine 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');
// Phones have no folder picker, so the page offers the ZIP there; desktop Chromium would offer the folder download.
$download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false));
$page = shotPage($download, 'phone', $theme);
$page->script("Alpine.\$data(document.querySelector('[data-test=share-file-list]')).supported = false");
shoot($page, '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');
// The text is fetched only when the recipient asks for it.
$text = visit(route('share.download', DemoData::TEXT_TOKEN, false));
$page = shotPage($text, 'phone', $theme);
$page->click('[data-test="show-text"]')
->waitForText('maple-lantern-8127')
->assertVisible('[data-test="shared-text"]');
shoot($page, 'phone', $theme, '05-private-text', DemoData::TEXT_TOKEN);
})->with(['light', 'dark']);