'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
, 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 email verification prompt holds at every breakpoint', function () { $this->actingAs(User::factory()->unverified()->create()); walkBreakpoints(ready(visit(route('verification.notice', [], false)))); }); 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', }; })()"); } }); // : 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; })()"); } }); // : 2 columns below `expanded` (840px), 4 from it. expect($columns[839])->toBe(2); expect($columns[840])->toBe(4); }); 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 //
, one

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); });