- 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>
266 lines
12 KiB
PHP
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);
|
|
});
|