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
@@ -7,3 +7,5 @@ Before planning or editing, find the row whose globs match the file's path and r
|
||||
| config/livewire-material.php | .ai/rules/config.md |
|
||||
| resources/css/material-scheme.* | .ai/rules/css.md |
|
||||
| resources/views/livewire/share-download.blade.php | .ai/rules/livewire.md |
|
||||
| tests/Screenshots/** | .ai/rules/screenshots.md |
|
||||
| website/** | .ai/rules/website.md |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
paths:
|
||||
- 'tests/Screenshots/**'
|
||||
---
|
||||
|
||||
# Screenshots
|
||||
|
||||
## Screenshots come from composer screenshots, before a release
|
||||
Run `composer screenshots` whenever the interface changes and before a release; it builds assets and runs tests/Screenshots (not part of any test suite or CI), publishing WebP files to website/img/screenshots. Demo data (DemoData) and the clock are fixed so runs are reproducible. Traps: Pest only starts its browser for a test whose body calls `visit(` after whitespace; Livewire's temporary-upload cleanup must stay off under the frozen clock or it deletes the selected files; the in-process server's random port is shown as https://files.example.com and the QR redrawn for it; upload_max_filesize/post_max_size are set to 4G by the script so the admin settings hint does not show the machine's PHP limit.
|
||||
@@ -25,6 +25,9 @@ Dockerfile
|
||||
|
||||
# Testing
|
||||
tests
|
||||
|
||||
# Website (published separately)
|
||||
website
|
||||
phpunit.xml
|
||||
.phpunit.cache
|
||||
|
||||
|
||||
@@ -25,3 +25,5 @@ yarn-error.log
|
||||
**/caddy
|
||||
frankenphp
|
||||
frankenphp-worker.php
|
||||
|
||||
/tests/Browser/Screenshots
|
||||
|
||||
@@ -84,6 +84,11 @@
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
],
|
||||
"screenshots": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npm run build",
|
||||
"@php -d upload_max_filesize=4G -d post_max_size=4G vendor/bin/pest tests/Screenshots"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
# Screenshots and the SealShare website
|
||||
|
||||
## Goal
|
||||
|
||||
Two things that feed each other. First, one command — `composer screenshots` — produces every
|
||||
screenshot of SealShare from fixed demo data, desktop and phone, light and dark, ready for the web.
|
||||
Second, a static website at **sealshare.nonameweb.ch**, made the way mailifysms.nonameweb.ch is:
|
||||
hand-written HTML and CSS in `website/`, uploaded by hand. The site presents SealShare as what it
|
||||
is — software a company installs to run **its own upload platform**, so it exchanges files with
|
||||
customers securely without relying on an outside service — shows the screenshots, compares
|
||||
SealShare with hosted transfer services and with other self-hosted tools, and tells how to install
|
||||
it. It goes live with 2.0.0. The README gets a few of the same screenshots, and its encryption
|
||||
wording is corrected.
|
||||
|
||||
## Context
|
||||
|
||||
**MailifySMS, the model** (`../MailifySMS`):
|
||||
|
||||
- `website/` holds `index.html`, `privacy_policy.html`, `terms_and_conditions.html`,
|
||||
`css/theme.css` (a palette sampled from the app's screenshots), `css/device-frame.css` (a phone
|
||||
bezel shared with the store canvases), self-hosted Poppins (`fonts/`, OFL) and `img/`
|
||||
(`icon.png`, `hero.jpg`, `screenshots/{light,dark}/NN-name.png` at 540px).
|
||||
- `index.html`: sticky nav with a phone toggle, hero, "How it works", "Key features", a screenshot
|
||||
gallery with a Light/Dark switch (`data-light`/`data-dark` on each `<img>`), FAQ accordion,
|
||||
contact card (`surtic86@gmail.com`), footer (quick links, legal). Plausible:
|
||||
`<script defer data-domain="mailifysms.nonameweb.ch" src="https://plausible.io/js/script.js">`.
|
||||
The page's JS is one inline `<script>` at the end.
|
||||
- `CLAUDE.md` records that `website/` "is a faithful copy of what is deployed, images included, so
|
||||
it can be uploaded wholesale". There is no deploy automation.
|
||||
- `tools/screenshots.sh` (macOS only) drives an emulator and headless Chrome; documented in
|
||||
`CLAUDE.md` § Screenshots, with the reasons behind each quirk.
|
||||
|
||||
**Hosting.** `*.nonameweb.ch` is a wildcard DNS record to `80.74.140.2` (METANET shared hosting,
|
||||
nginx), the same as mailifysms. `sealshare.nonameweb.ch` resolves already; HTTP serves the host's
|
||||
placeholder, HTTPS has no certificate. Creating the site and its Let's Encrypt certificate is done
|
||||
in the hosting panel.
|
||||
|
||||
**SealShare.**
|
||||
|
||||
- Laravel 13.31, Livewire 4.4, Livewire Material 1.0.1, Pest 5.1 with `pestphp/pest-plugin-browser`
|
||||
(Playwright 1.63). The browser tests run the app in-process, so factories, `Storage::fake()` and
|
||||
`travelTo()` shape what the browser sees. `tests/Pest.php` applies `Tests\TestCase` and
|
||||
`RefreshDatabase` to `Feature` and `Browser`, and creates an admin in `beforeEach` (the setup
|
||||
gate).
|
||||
- Device presets: `visit()->on()->macbook14()` is 1512×982 at 2× (a 3024×1964 capture);
|
||||
`on()->iPhone15Pro()` is 393×852 at 3× (1179×2556). `inLightMode()` / `inDarkMode()`,
|
||||
`screenshot(fullPage, filename)`. Screenshots are written to `tests/Browser/Screenshots/<name>.png`;
|
||||
the directory is created, subdirectories in the name are not — names must be flat. Pest empties
|
||||
that directory when a browser run starts, and has no reduced-motion emulation.
|
||||
- Pest's in-process server does not store a multipart upload, so a browser test cannot select files
|
||||
through the file input.
|
||||
- PHP here has GD with WebP and PNG support; the production image does not need it (this is a
|
||||
development tool).
|
||||
- Colours: `resources/css/material-scheme.json` (seed `#4f46e5`, Vibrant) holds the light and dark
|
||||
roles as hexes. Font: Google Sans Flex, `vendor/nonameweb/livewire-material/resources/fonts/google-sans-flex/GoogleSansFlex-Latin.woff2`
|
||||
with its `OFL.txt`. Logo: `resources/views/components/app-logo-icon.blade.php` (SVG).
|
||||
- **Encryption, as the code does it:** `ShareService::createShare()` encrypts each uploaded file on
|
||||
the server with AES-256-GCM (chunked) through `FileEncryptionService`. Without a share password
|
||||
the key is stored in `shares.encryption_key`; with one, the key is derived with PBKDF2-SHA256 and
|
||||
never stored. The server sees the plaintext while uploading and downloading. The README calls
|
||||
this "End-to-End Encryption", which it is not.
|
||||
- The upload page is public, optionally behind the system password (`SystemPasswordGate`); a
|
||||
customer given that password can upload and send the link back.
|
||||
- Install today (README): `ghcr.io/surtic86/sealshare`, clone from GitHub. Gitea
|
||||
(`gitea.nonameweb.ch/noNameWEB/SealShare`) is now the public repository; the image registry for
|
||||
2.0.0 is settled separately.
|
||||
- `.gitignore` does not ignore `tests/Browser/Screenshots`; `.dockerignore` excludes `tests` and
|
||||
`*.md` but would copy a `website/` directory into the image.
|
||||
|
||||
**Peers.** Pingvin Share has been archived since June 2025 (its README points to forks such as
|
||||
Pingvin Share X). PsiTransfer, Gokapi and Erugo are single-purpose self-hosted share tools; Gokapi
|
||||
advertises end-to-end encryption.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Positioning: software you host, not a service** — the site says plainly that SealShare is not
|
||||
hosted by anyone but the company that installs it: its own upload platform for exchanging files
|
||||
with customers, data on its own server, no dependence on an external service.
|
||||
- **Pages: `index.html` and `privacy.html`** — one landing page, and a short privacy page because
|
||||
the site uses Plausible. No terms page: the software is MIT-licensed and no service is offered.
|
||||
- **A comparison with hosted transfer services and with self-hosted share tools** — two tables on
|
||||
the landing page. Cloud suites (Nextcloud-style) are left out.
|
||||
- **Hand-written HTML and CSS, like MailifySMS** — no build step; `website/` is uploaded as it is.
|
||||
- **Colours copied from `material-scheme.json`, not sampled** — `website/css/theme.css` lists the
|
||||
roles it uses with the scheme's hexes, light by default and dark under
|
||||
`@media (prefers-color-scheme: dark)`; it names the seed and variant it was copied from, so a
|
||||
regenerated scheme is copied again. The site follows the visitor's system theme and has no
|
||||
toggle of its own.
|
||||
- **Google Sans Flex, self-hosted** — the app's font, copied with its `OFL.txt` into
|
||||
`website/fonts/`; nothing is loaded from Google.
|
||||
- **Uploaded by hand, like MailifySMS** — `website/` is a faithful copy of what is live. You create
|
||||
the subdomain and certificate once in the hosting panel and upload the folder when it changes.
|
||||
No hosting credentials anywhere in the repository or CI.
|
||||
- **Plausible** — `data-domain="sealshare.nonameweb.ch"`, the same script as MailifySMS; the site
|
||||
must be added in the Plausible account.
|
||||
- **English only.**
|
||||
- **Screenshots: desktop and phone, each in light and dark (20 images)** —
|
||||
desktop (MacBook 14, 2×): `01-upload` (files selected, options filled), `02-share-created`,
|
||||
`03-qr-code` (the dialog), `04-download` (the recipient's file list), `05-admin-dashboard`,
|
||||
`06-admin-settings`; phone (iPhone 15 Pro, 3×): `01-upload`, `02-password` (the recipient's
|
||||
password prompt), `03-download`, `04-qr-code`.
|
||||
- **Screenshots run as Pest browser tests in `tests/Screenshots/`, started by `composer screenshots`**
|
||||
— the directory is not one of phpunit.xml's test suites, so `php artisan test`, the Browser
|
||||
suite and CI never run it. It reuses Playwright and the in-process server.
|
||||
- **Fixed demo data** — factories and `ShareService` with fixed names, sizes and tokens, time
|
||||
frozen with `travelTo()`, the site title and branding at their defaults, one admin
|
||||
("Alex Morgan"). Every run produces the same images unless the UI changed.
|
||||
- **Images published as WebP by the test run itself** — after each capture a small helper resizes
|
||||
it with GD into `website/img/screenshots/{desktop,phone}/{light,dark}/NN-name-<width>.webp` at two widths
|
||||
(desktop 1600 and 800 px, phone 1080 and 540 px) for `srcset`. Raw PNGs stay in
|
||||
`tests/Browser/Screenshots/`, which is gitignored; only the WebP files are committed.
|
||||
- **Device frames in CSS** — `website/css/device-frame.css` draws a laptop and a phone around the
|
||||
screenshots; the hero shows the desktop upload and the phone download screenshots framed, in
|
||||
the visitor's theme. No generated hero image.
|
||||
- **README shows three screenshots** — desktop upload, desktop share created, phone download (light),
|
||||
referenced from `website/img/screenshots/…`, so each image exists once in the repository.
|
||||
- **Encryption is described accurately, on the site and in the README** — "encrypted at rest with
|
||||
AES-256-GCM; with a share password the key is never stored". The comparison marks end-to-end
|
||||
encryption "no" for SealShare. The README's "End-to-End Encryption" line is corrected.
|
||||
- **Built on `material`, live with 2.0.0** — screenshots show the 2.0.0 interface; the site links
|
||||
the Gitea repository, and its install commands are the README's at release, whatever registry
|
||||
2.0.0 ships with.
|
||||
- **Comparison facts are researched, dated and sourced** — from each product's own site,
|
||||
documentation or repository; the tables say "as of <month year>" and link every source. Hosted
|
||||
services: WeTransfer, SwissTransfer, Dropbox Transfer, Google Drive links. Self-hosted tools:
|
||||
open source, single-purpose, installable with Docker, with a release in the 12 months before the
|
||||
research — expected Pingvin Share X, PsiTransfer, Gokapi and Erugo; any that fails the rule is
|
||||
dropped and named in the commit message. Criteria (rows): where files are stored, who operates
|
||||
it, recipient needs an account, password protection, expiry, download limit, encryption at rest,
|
||||
end-to-end encryption, folder upload, custom branding, maximum file size, licence and cost. A
|
||||
value that cannot be sourced is "—", never guessed.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- A terms page, a documentation section, a German version.
|
||||
- Deploy automation (Gitea Actions, SFTP scripts), and creating the subdomain, certificate or
|
||||
Plausible site — those are yours in the hosting panel and the Plausible account.
|
||||
- Comparing with Nextcloud, ownCloud or other cloud suites.
|
||||
- Store-style canvases with captions, a generated hero image, video or animated screenshots.
|
||||
- Running the screenshots in CI, or checking them against earlier runs (visual regression).
|
||||
- The image registry move and the install commands' final form (settled with 2.0.0).
|
||||
- Implementing end-to-end encryption.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Housekeeping.** `.gitignore`: `/tests/Browser/Screenshots`. `.dockerignore`: `website`.
|
||||
2. **Screenshot helper.** `tests/Screenshots/Publisher.php` (`Tests\Screenshots\Publisher`):
|
||||
`publish(string $capture, string $device, string $theme, string $name, array $widths): void`
|
||||
reads `tests/Browser/Screenshots/<capture>.png` with GD, and for each width writes
|
||||
`website/img/screenshots/<device>/<theme>/<name>-<width>.webp` (quality 82, aspect kept,
|
||||
directories created). It throws when the capture is missing, so a failed shot fails the run.
|
||||
3. **Demo data.** `tests/Screenshots/DemoData.php`: `admin()`, `shares()` (eight shares with fixed
|
||||
tokens, file names such as `Q3-report.pdf`, `Contract 2026.pdf`, `Product photos/…`, sizes,
|
||||
download counts and expiries, one password-protected, one expired), created through factories
|
||||
and `ShareService` with `Storage::fake('shares')` so the files exist encrypted.
|
||||
4. **Screenshot tests.** `tests/Screenshots/ScreenshotsTest.php`, with `tests/Pest.php` extended to
|
||||
`->in('Feature', 'Browser', 'Screenshots')`:
|
||||
- `beforeEach`: `config(['session.driver' => 'file'])`, `travelTo('2026-10-01 09:30')`, demo
|
||||
data; a `ready()` wait as in `SealShareTest`.
|
||||
- One test per device and theme (four tests), each visiting the pages in turn, waiting for
|
||||
`networkidle` and fonts (`document.fonts.ready`), hiding the text caret, capturing
|
||||
viewport-sized (not full-page) shots, and calling `Publisher::publish()` right after each.
|
||||
- The upload shot with files selected: create Livewire temporary uploads on the fake
|
||||
`livewire-tmp` disk and set the uploader's property through `$wire.$set` with
|
||||
`livewire-file:` references, then fill the options. If Livewire refuses that, the shot shows
|
||||
the drop zone with the options filled instead, and the plan's risk note is updated.
|
||||
- QR dialog: `click('[data-test="show-qr-code"]')`; password prompt: the protected share on the
|
||||
phone; admin pages as the admin.
|
||||
5. **Command.** `composer.json` script `"screenshots"`: `Composer\\Config::disableProcessTimeout`,
|
||||
`npm run build`, `@php vendor/bin/pest tests/Screenshots` — the build first, so the shots show
|
||||
the current assets. Playwright's Chromium must be installed (`npx playwright install chromium`),
|
||||
as for the browser tests.
|
||||
6. **Website scaffold.** `website/`:
|
||||
- `css/theme.css` — tokens copied from `material-scheme.json` (with seed and variant noted),
|
||||
Google Sans Flex `@font-face`, layout, nav, hero, sections, cards, tables (scrolling
|
||||
sideways on a phone), FAQ (`<details>`), footer; light and dark through
|
||||
`prefers-color-scheme`.
|
||||
- `css/device-frame.css` — laptop and phone frames.
|
||||
- `fonts/GoogleSansFlex-Latin.woff2`, `fonts/OFL.txt`; `img/logo.svg` (from `app-logo-icon`),
|
||||
`img/icon.png` (favicon, from `public/`).
|
||||
7. **Landing page.** `website/index.html` (Plausible in `<head>`, one inline script at the end):
|
||||
- nav: Why, Features, Screenshots, Compare, Install, FAQ, Gitea;
|
||||
- hero: "Your own secure upload platform" — self-hosted file exchange with customers, no
|
||||
outside service; buttons "Install" (to #install) and "Source on Gitea"; framed desktop and
|
||||
phone screenshots as `<picture>` elements whose `<source media="(prefers-color-scheme: dark)">`
|
||||
picks the dark captures;
|
||||
- "Why run your own": your server, your domain and branding, customers upload and download
|
||||
without accounts, encrypted at rest, no per-seat pricing;
|
||||
- "How it works": upload → link or QR code → the customer downloads, with expiry, download
|
||||
limit and password;
|
||||
- features (from the README, accurate encryption wording);
|
||||
- screenshots: Desktop/Phone and Light/Dark switches over one gallery (Light/Dark starting on
|
||||
the visitor's system theme), `srcset` for both widths, `loading="lazy"`, descriptive `alt`;
|
||||
- compare: the two dated tables with sources (step 8);
|
||||
- install: the README's Docker quick start and a link to the full instructions on Gitea;
|
||||
- FAQ: "Is it end-to-end encrypted?" (no — at rest, and what a password adds), "Can customers
|
||||
send files to us?" (yes, through the upload page, optionally behind the system password),
|
||||
"How big can files be?" (the README's large-file limits), "What does it cost?" (MIT, your
|
||||
hosting), "Who runs it?" (you);
|
||||
- contact (`surtic86@gmail.com`, as MailifySMS) and footer (Gitea, licence, privacy, noNameWEB).
|
||||
8. **Comparison research.** For each product, record every criterion with its source URL and the
|
||||
date checked; apply the self-hosted selection rule; fill the tables. Keep the notes in the
|
||||
commit message, not in the repository.
|
||||
9. **Privacy page.** `website/privacy.html`: who runs the site (contact), the host (METANET, server
|
||||
logs), Plausible (cookieless, no personal data, EU-hosted, link to its data policy), no other
|
||||
third parties, fonts served locally, contact for questions; dated.
|
||||
10. **README and changelog.** Correct the encryption lines (the intro sentence stays accurate;
|
||||
"End-to-End Encryption" becomes "Encryption at Rest", described as in Decisions), add a
|
||||
Screenshots section with the three images, add the website link. CHANGELOG `2.0.0` "Fixed":
|
||||
the README no longer calls the encryption end-to-end. The website and the screenshot tooling
|
||||
get no changelog entry — they do not change the application.
|
||||
11. **Project notes.** `record-rule` for `website/**`: `website/` is a faithful copy of what is live,
|
||||
uploaded by hand; its colours are copied from `material-scheme.json` and must be copied again
|
||||
when the scheme is regenerated; the comparison is dated and every value sourced. And for
|
||||
`tests/Screenshots/**`: run with `composer screenshots` whenever the interface changes, before
|
||||
a release; the demo data is fixed so runs are reproducible.
|
||||
|
||||
## Testing
|
||||
|
||||
- `tests/Unit/ScreenshotPublisherTest.php`: a generated PNG is written as WebP at each requested
|
||||
width with the aspect ratio kept, into the device/theme directory; a missing capture throws.
|
||||
- `tests/Feature/WebsiteTest.php` guards `website/` without a browser:
|
||||
- every local `src`, `href`, `srcset` entry and CSS `url()` resolves to a file in `website/`;
|
||||
- every screenshot the gallery or README references exists for both widths and both themes;
|
||||
- no request goes to a host other than `plausible.io` (no Google Fonts, no CDN);
|
||||
- `index.html` and `privacy.html` have a `<title>`, `lang="en"` and a meta description;
|
||||
- the README has no "End-to-End Encryption" line, the site's features section does not say
|
||||
"end-to-end", and SealShare's end-to-end cell in the comparison (marked
|
||||
`data-compare="sealshare-e2e"`) reads "No" — the FAQ may still ask the question.
|
||||
- The screenshot run itself is the test of step 4: it fails when a page, selector or capture
|
||||
breaks. It is run by hand before a release, not in CI.
|
||||
- The site is looked at in Chrome, Firefox and Safari, light and dark, at phone width, before
|
||||
uploading.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Selecting files in the upload shot** relies on Livewire accepting `livewire-file:` references set
|
||||
from the browser; if it does not, that shot shows the empty drop zone with options (step 4).
|
||||
- **Comparison accuracy and fairness.** Other products change; the tables are dated and sourced,
|
||||
and re-checked when the site is updated. Swiss unfair-competition law expects comparisons to be
|
||||
accurate and not misleading — values that cannot be sourced stay "—".
|
||||
- **Install commands depend on the registry move.** Until it is settled, the install section copies
|
||||
the current README; it is updated before the site goes live with 2.0.0.
|
||||
- **Screenshot determinism.** Relative dates ("in 3 days") depend on `travelTo()`; animations
|
||||
(the share-created shape, counting stats, dialog entry) are waited out — Pest has no
|
||||
reduced-motion emulation — by waiting on `document.getAnimations().length === 0` before each
|
||||
capture.
|
||||
- **The host's name in the privacy page** (METANET) is inferred from the server's reverse DNS
|
||||
(`urbanus.ch-meta.net`); confirm it before the page goes live.
|
||||
- **The contact address** is the one MailifySMS publishes (`surtic86@gmail.com`); change it in
|
||||
step 7 if SealShare should have its own.
|
||||
- **Image weight.** Twenty screenshots at two widths as WebP should stay under ~4 MB in total; if
|
||||
not, lower the quality or drop the larger phone width.
|
||||
- **Colours drift** when the scheme is regenerated; the rule in step 11 and the note in
|
||||
`theme.css` are the guard.
|
||||
@@ -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');
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 14 KiB |