'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', '')"); $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 $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']);