Files
SealShare/tests/Browser/BreakpointsTest.php
T
surtic86andClaude Opus 5 4530298398
docker / test (8.5) (push) Successful in 3m10s
linter / quality (push) Successful in 1m5s
tests / ci (8.5) (push) Successful in 3m9s
docker / build-and-push (push) Successful in 21m5s
docker / release (push) Skipped
Cut over-engineering found by a repo-wide audit
- Config: auth, services, logging, queue and database only repeated the
  framework's own files and are gone; the others keep only the keys that
  differ (app version, cache serializable_classes, session cookie name,
  Markdown mail theme, the shares disk, three Octane values, Livewire's
  pagination theme and payload guards).
- Email verification is removed: User never implemented MustVerifyEmail,
  so it was never enforced, and SealShare has a single admin and no
  registration. CreateNewUser goes with it.
- FileEncryptionService::encryptFile() and generateSalt() were only used
  by tests; tests build files with encryptTestFile() in tests/Pest.php.
- The expiration options are defined once, as Share::EXPIRATIONS. "30 Days"
  now lasts 30 days instead of a calendar month, and Admin settings only
  save a default expiration that is one of the options.
- One-caller helpers are inlined, the uploader reads chunk responses with
  XHR's responseType, and starter-kit leftovers are removed.
- Docker: PHP reads the PHP_* limits from the environment itself
  (${VAR:-default} in uploads.ini); both entrypoints stop writing the ini.
  docker-compose.yml shares the app and scheduler variables through one
  anchor. The dev image installs gd for the screenshot publisher and fake
  test images.
- Development runs in Docker only: the composer dev script, concurrently,
  laravel/pail, laravel/sail, autoprefixer and the shell-quote override
  are gone.
- phpunit.xml forces the test environment with <server> entries, so tests
  run in the dev container no longer use its real database.

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

266 lines
12 KiB
PHP

<?php
use App\Models\Setting;
use App\Models\Share;
use App\Models\User;
use App\Services\ShareService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
/**
* The walk: every page's chrome and content across M3's breakpoint edges — 599, 600, 839, 840,
* 1199, 1200, 1600px, height 900, light theme — one visit per page, reused across widths by
* resizing the same page rather than revisiting it. FrameTest.php and SettingsAndAdminTest.php
* already assert the app-main margin, the page column's width and the stat grid's column count
* at their own boundary for the pages they cover; this file re-asserts those three plus the
* section navigation's picker/tab-bar switch (untested until now), and walks every page group A/B/C
* left unchecked at a breakpoint: the rest of the auth flow, the setup wizard, the system password
* prompt, every settings page and a 404.
*/
beforeEach(function () {
config(['session.driver' => 'file']);
Storage::fake('shares');
});
/**
* Resize $page to each of M3's seven edge widths and assert what must hold at all of them: no
* horizontal overflow, exactly one <main>, no skipped heading level, and — scrolled to the bottom —
* the floating toolbar covers no interactive element and no visible text. $buttonSelector, when
* given, is also asserted narrower than the form it sits in (M3: a button's width is "dynamic to
* fit label", never stretched). $atEachWidth, when given, runs after those checks with the page and
* the current width, for a caller's own edge-specific assertions without a resize of their own.
*/
function walkBreakpoints($page, ?string $buttonSelector = null, ?callable $atEachWidth = null): void
{
foreach ([599, 600, 839, 840, 1199, 1200, 1600] as $width) {
$page->resize($width, 900);
$metrics = $page->script(<<<'JS'
(() => {
const overflowOk = document.documentElement.scrollWidth <= window.innerWidth + 1;
const mainCount = document.querySelectorAll('main').length;
const levels = [...document.querySelectorAll('h1, h2, h3, h4, h5, h6')]
.filter((h) => h.offsetParent !== null)
.map((h) => parseInt(h.tagName.slice(1), 10));
let seen = 0;
let headingSkipped = false;
for (const level of levels) {
if (level > seen + 1) {
headingSkipped = true;
break;
}
seen = Math.max(seen, level);
}
window.scrollTo(0, document.body.scrollHeight);
const toolbar = document.querySelector('[data-test="app-toolbar"]');
let toolbarClear = true;
if (toolbar) {
const t = toolbar.getBoundingClientRect();
const clearOf = (r) => r.right <= t.left + 0.5 || r.left >= t.right - 0.5
|| r.bottom <= t.top + 0.5 || r.top >= t.bottom - 0.5;
const interactive = [...document.querySelectorAll('a, button, input, select, textarea, [tabindex]')];
const textLeaves = [...document.querySelectorAll('*')]
.filter((el) => el.children.length === 0 && el.textContent.trim().length > 0);
toolbarClear = [...new Set([...interactive, ...textLeaves])]
.filter((el) => !toolbar.contains(el))
.every((el) => {
const r = el.getBoundingClientRect();
return (r.width === 0 || r.height === 0) || clearOf(r);
});
}
return { overflowOk, mainCount, headingSkipped, toolbarClear };
})()
JS);
expect($metrics['overflowOk'])->toBeTrue();
expect($metrics['mainCount'])->toBe(1);
expect($metrics['headingSkipped'])->toBeFalse();
expect($metrics['toolbarClear'])->toBeTrue();
if ($buttonSelector !== null) {
$fits = $page->script("(() => {
const button = document.querySelector('{$buttonSelector}');
if (! button) { return null; }
const form = button.closest('[data-md-form]') ?? button.parentElement;
const formRect = form.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect();
return formRect.width === 0 ? null : buttonRect.width < formRect.width - 0.5;
})()");
if ($fits !== null) {
expect($fits)->toBeTrue();
}
}
if ($atEachWidth !== null) {
$atEachWidth($page, $width);
}
}
}
test('the upload page holds at every breakpoint, guest and admin', function (?string $as) {
if ($as === 'admin') {
$this->actingAs(User::factory()->admin()->create());
}
$margins = [];
walkBreakpoints(ready(visit('/upload')), null, function ($page, $width) use (&$margins) {
if (in_array($width, [599, 600], true)) {
$margins[$width] = $page->script("(() => {
const style = getComputedStyle(document.querySelector('[data-md-pane-body]'));
return { left: parseFloat(style.paddingLeft), right: parseFloat(style.paddingRight) };
})()");
}
});
// M3's margin: 16px below `medium` (600px), 24px from it (foundations.md § Layout → Breakpoints).
expect($margins[599]['left'])->toEqualWithDelta(16, 1);
expect($margins[599]['right'])->toEqualWithDelta(16, 1);
expect($margins[600]['left'])->toEqualWithDelta(24, 1);
expect($margins[600]['right'])->toEqualWithDelta(24, 1);
})->with([
'guest' => [null],
'admin' => ['admin'],
]);
test('the share-created page holds at every breakpoint', function () {
$share = app(ShareService::class)->createShare(
[['file' => UploadedFile::fake()->create('holiday-photos.zip', 100), 'relativePath' => null]],
[],
);
walkBreakpoints(ready(visit(route('share.created', $share, false))));
});
test('the download page holds locked and unlocked at every breakpoint', function () {
$share = app(ShareService::class)->createShare(
[['file' => UploadedFile::fake()->create('holiday-photos.zip', 100), 'relativePath' => null]],
['password' => 'let-me-in'],
);
$page = ready(visit(route('share.download', $share, false)));
walkBreakpoints($page, 'button[type="submit"]');
$page->resize(1280, 900);
$page->type('input[type="password"]', 'let-me-in')->press('Unlock');
$page->wait(1);
walkBreakpoints($page);
});
test('the auth flow pages hold at every breakpoint', function (Closure $url, string $buttonSelector) {
walkBreakpoints(ready(visit($url())), $buttonSelector);
})->with([
'login' => [fn () => route('login', [], false), '[data-test="login-button"]'],
'forgot password' => [fn () => route('password.request', [], false), '[data-test="email-password-reset-link-button"]'],
'reset password' => [fn () => route('password.reset', ['token' => 'a-fake-token', 'email' => 'test@example.com'], false), '[data-test="reset-password-button"]'],
]);
test('the two-factor challenge page holds at every breakpoint', function () {
$user = User::factory()->create();
$this->withSession(['login.id' => $user->getKey()]);
walkBreakpoints(ready(visit(route('two-factor.login', [], false))), 'button[type="submit"]');
});
test('the password confirmation page holds at every breakpoint', function () {
$this->actingAs(User::factory()->create());
walkBreakpoints(ready(visit(route('password.confirm', [], false))), '[data-test="confirm-password-button"]');
});
test('the setup wizard holds at every breakpoint', function () {
// EnsureSetupComplete only renders /setup while no admin exists; Pest.php's beforeEach creates
// one for every test, so this one removes it first.
User::query()->where('is_admin', true)->delete();
walkBreakpoints(ready(visit(route('setup', [], false))), 'button[type="submit"]');
});
test('the system password prompt holds at every breakpoint', function () {
Setting::set('system_password', bcrypt('secret'));
walkBreakpoints(ready(visit(route('system-password', [], false))), 'button[type="submit"]');
});
test('the settings pages hold at every breakpoint', function (string $url, ?string $buttonSelector) {
$user = User::factory()->create();
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => time()]);
$navSwitch = [];
walkBreakpoints(ready(visit($url)), $buttonSelector, function ($page, $width) use (&$navSwitch) {
if (in_array($width, [599, 600], true)) {
$navSwitch[$width] = $page->script("(() => {
const picker = document.querySelector('[data-md-section-nav-picker]');
const nav = document.querySelector('[data-md-section-nav] > nav');
return {
pickerVisible: !!picker && getComputedStyle(picker).display !== 'none',
navVisible: !!nav && getComputedStyle(nav).display !== 'none',
};
})()");
}
});
// <x-section-nav>: a picker below `medium` (600px), M3's secondary tabs from it (section-nav.css).
expect($navSwitch[599]['pickerVisible'])->toBeTrue();
expect($navSwitch[599]['navVisible'])->toBeFalse();
expect($navSwitch[600]['pickerVisible'])->toBeFalse();
expect($navSwitch[600]['navVisible'])->toBeTrue();
})->with([
'profile' => ['/settings/profile', '[data-test="update-profile-button"]'],
'password' => ['/settings/password', '[data-test="update-password-button"]'],
'appearance' => ['/settings/appearance', null],
'two-factor' => ['/settings/two-factor', null],
]);
test('the admin dashboard holds at every breakpoint', function () {
$this->actingAs(User::factory()->admin()->create());
Share::factory()->count(3)->create();
$columns = [];
walkBreakpoints(ready(visit('/admin/dashboard')), null, function ($page, $width) use (&$columns) {
if (in_array($width, [839, 840], true)) {
$columns[$width] = $page->script("(() => {
const rects = [...document.querySelectorAll('[data-md-stat]')].map((el) => el.getBoundingClientRect());
return new Set(rects.map((r) => Math.round(r.left))).size;
})()");
}
});
// <x-grid :columns="2">: two columns at every width, since the page's column is 40rem at all of them.
expect($columns[839])->toBe(2);
expect($columns[840])->toBe(2);
});
test('the admin settings page holds at every breakpoint', function () {
$this->actingAs(User::factory()->admin()->create());
walkBreakpoints(ready(visit('/admin/settings')), '[data-test="save-settings"]');
});
test('a 404 holds at every breakpoint', function () {
// No resources/views/errors/404.blade.php exists in SealShare, but that is not Laravel's own
// minimal fallback either: NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider appends
// its own error-view root to `view.paths`, so `errors::404` resolves to the package's own
// resources/views/error-pages/errors/404.blade.php first (verified by rendering the route
// in-process: the response carries `data-md-error-page`) — a whole document with its own
// <main data-md-error-page>, one <h1 data-md-error-headline> and no toolbar (it does not include
// partials.toolbar), never Alpine or Livewire, so ready() does not apply; a network-idle wait
// stands in for it.
config(['app.debug' => false]);
$page = visit('/this-page-does-not-exist-at-all')->waitForEvent('networkidle');
walkBreakpoints($page);
});