The pages were built five ways: two layouts, seven widths from 28 to 64rem and four heading styles. Every page now looks like the share pages: a centred heading over one 40rem column of outlined cards, with the floating toolbar below. - <x-page> (components/page.blade.php) is the root of every page. It draws the h1 and its line (`brand` takes the site's logo, title and description from Admin settings), an optional `mark` and `navigation` slot, then the content. It has no width prop: every page is the same <x-pane width="narrow">. - The sign-in, password reset, confirm, verify email, two-factor challenge, setup and system password pages move onto layouts/app with the brand heading and their form in a card titled with the task. layouts/auth, auth-header and the settings heading partial are gone, and so is the per-page width CSS. - Settings put their section nav under the heading; the admin pages get a description line each. FileUploader and ShareDownload no longer pass the branding to their views. - The admin dashboard's table needed about 49rem, so its shares are a list: created above the token, which opens the share, then files, size, downloads and expiry on two lines that wrap instead of clipping, and one delete button. A "Sort by" select replaces the column headers (newest, oldest, expiring soonest with never-expiring last, largest, most downloads, most files) and resets the page. The stats stay two by two. table.css and sort-header.css are no longer imported. - Branding hints in Admin settings name every page the title shows on. - Tests: PageTemplateTest renders every page once and checks one page template, one h1 and the width, and the brand heading with its fallbacks. FrameTest measures the page column instead of the auth card and the 64rem main; dashboard tests follow the list and the sort select, including expiry order. .ai/rules/views.md records <x-page>, the CHANGELOG notes the change and the website screenshots are regenerated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
223 lines
10 KiB
PHP
223 lines
10 KiB
PHP
<?php
|
|
|
|
use App\Models\Share;
|
|
use App\Models\User;
|
|
use App\Services\ShareService;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* The share flow (upload, share created, share download) and today's rework of it, plus the
|
|
* settings/admin buttons and sections reworked alongside it — all changed since the last browser
|
|
* run (148ff38) without one. tests/Browser/SealShareTest.php, FrameTest.php and
|
|
* SettingsAndAdminTest.php already cover the flow's existing behaviour; this file adds what that
|
|
* rework introduced and does not re-assert what those already do (the copy-to-clipboard toast, the
|
|
* dragover burst, the QR code's fill and PNG export).
|
|
*/
|
|
beforeEach(function () {
|
|
config(['session.driver' => 'file']);
|
|
Storage::fake('shares');
|
|
});
|
|
|
|
test('tab reaches Browse Files with its focus ring, and Enter or Space opens the file picker', function () {
|
|
$page = ready(visit('/upload'));
|
|
|
|
// A spy, not a real dialog: Playwright would otherwise have to field a native file chooser.
|
|
$page->script("window.eval(\"window.__fileInputClicks = 0; HTMLInputElement.prototype.click = function () { if (this.type === 'file') { window.__fileInputClicks++ } }\")");
|
|
|
|
// "body" alone is not a CSS-explicit selector to this plugin's guesser (no special chars) and
|
|
// falls back to a text search, which never matches and times out; "html > body" is explicit and
|
|
// focuses nothing new (the body takes no tabindex), so the Tab lands where a fresh page load
|
|
// would send it: the first tabbable element.
|
|
$page->keys('html > body', 'Tab');
|
|
|
|
$page->assertScript("document.activeElement.matches('[data-md-button]') && document.activeElement.textContent.trim() === 'Browse Files'");
|
|
|
|
$ring = $page->script('getComputedStyle(document.activeElement).outlineStyle');
|
|
expect($ring)->toBe('solid');
|
|
|
|
$page->keys(':focus', 'Enter');
|
|
$page->assertScript('window.__fileInputClicks === 1');
|
|
|
|
$page->keys(':focus', 'Space');
|
|
$page->assertScript('window.__fileInputClicks === 2')
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
test('the selected files list avoids horizontal overflow once files are chosen', function () {
|
|
$path = sys_get_temp_dir().'/a-genuinely-quite-long-holiday-photos-archive-from-portugal-'.uniqid().'.zip';
|
|
file_put_contents($path, 'archive');
|
|
|
|
$page = ready(visit('/upload')->resize(393, 852));
|
|
$page->attach('[data-test="file-input"]', $path)
|
|
->waitForText('Uploaded');
|
|
|
|
$rows = $page->script("(() => {
|
|
const rows = [...document.querySelectorAll('[data-test=selected-file]')];
|
|
return { count: rows.length, allFit: rows.every((row) => row.getBoundingClientRect().right <= window.innerWidth + 0.5) };
|
|
})()");
|
|
|
|
expect($rows['count'])->toBe(1);
|
|
expect($rows['allFit'])->toBeTrue();
|
|
$page->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
|
|
->assertNoJavaScriptErrors();
|
|
|
|
unlink($path);
|
|
});
|
|
|
|
test('without a secure context the upload page says HTTPS is needed and takes no files', function () {
|
|
$page = ready(visit('/upload'));
|
|
|
|
$page->script("window.eval(\"Alpine.\$data(document.querySelector('[data-test=drop-zone]')).secure = false\")");
|
|
|
|
$page->assertScript("getComputedStyle(document.querySelector('[data-test=insecure-context]')).display !== 'none'")
|
|
->assertSee('Uploads need a secure connection (HTTPS).')
|
|
->assertScript("document.querySelector('[data-test=file-input]').disabled === true")
|
|
->assertScript("document.querySelector('[data-test=drop-zone]').getAttribute('aria-disabled') === 'true'")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
test('the drop zone hides its burst again once the drag leaves', function () {
|
|
$page = ready(visit('/upload'));
|
|
|
|
$burst = "getComputedStyle(document.querySelector('[data-test=drop-zone-burst]')).opacity";
|
|
|
|
$page->assertScript("{$burst} === '0'");
|
|
|
|
$page->script("window.eval(\"document.querySelector('[data-test=drop-zone]').dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true }))\")");
|
|
$page->assertScript("{$burst} === '1'");
|
|
|
|
$page->script("window.eval(\"document.querySelector('[data-test=drop-zone]').dispatchEvent(new DragEvent('dragleave', { bubbles: true, cancelable: true }))\")");
|
|
$page->assertScript("{$burst} === '0'")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
test('the QR dialog holds its code inside the box, and Escape returns focus to the button that opened it', function () {
|
|
$share = Share::factory()->create();
|
|
|
|
$page = ready(visit(route('share.created', $share, false)));
|
|
|
|
$page->click('[data-test="show-qr-code"]');
|
|
$page->wait(1);
|
|
|
|
$page->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
|
|
|
|
$inside = $page->script("(() => {
|
|
const dialog = document.querySelector('[data-test=\"qr-code-dialog\"]').getBoundingClientRect();
|
|
const qr = document.querySelector('[data-qr-code] svg').getBoundingClientRect();
|
|
return qr.left >= dialog.left - 1 && qr.right <= dialog.right + 1
|
|
&& qr.top >= dialog.top - 1 && qr.bottom <= dialog.bottom + 1;
|
|
})()");
|
|
expect($inside)->toBeTrue();
|
|
|
|
$page->keys(':focus', 'Escape');
|
|
$page->wait(1);
|
|
|
|
$page->assertScript("! document.querySelector('[data-test=\"qr-code-dialog\"]').open")
|
|
->assertScript("document.activeElement === document.querySelector('[data-test=\"show-qr-code\"]')")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
test('the download page fits a phone before and after unlocking a password-protected share', function () {
|
|
$share = app(ShareService::class)->createShare(
|
|
[['file' => UploadedFile::fake()->create('a-genuinely-quite-long-holiday-photos-archive-from-portugal.zip', 120), 'relativePath' => null]],
|
|
['password' => 'let-me-in'],
|
|
);
|
|
|
|
$page = ready(visit(route('share.download', $share, false))->resize(393, 852));
|
|
|
|
$metrics = $page->script("(() => {
|
|
const button = document.querySelector('button[type=submit]');
|
|
const form = button.closest('[data-md-form]').getBoundingClientRect();
|
|
const rect = button.getBoundingClientRect();
|
|
return { formWidth: form.width, formRight: form.right, buttonWidth: rect.width, buttonRight: rect.right };
|
|
})()");
|
|
|
|
expect($metrics['buttonWidth'])->toBeLessThan($metrics['formWidth']);
|
|
expect($metrics['buttonRight'])->toEqualWithDelta($metrics['formRight'], 1);
|
|
$page->assertScript('document.documentElement.scrollWidth <= window.innerWidth');
|
|
|
|
$page->type('input[type="password"]', 'let-me-in')->press('Unlock');
|
|
$page->wait(1);
|
|
|
|
$page->assertScript("document.querySelector('h2').tagName === 'H2' && document.querySelector('h2').textContent.trim() === 'Shared Files'");
|
|
|
|
$rows = $page->script("(() => {
|
|
const rows = [...document.querySelectorAll('[data-md-list-item]')];
|
|
return {
|
|
count: rows.length,
|
|
allFit: rows.every((row) => row.getBoundingClientRect().right <= window.innerWidth + 0.5),
|
|
};
|
|
})()");
|
|
|
|
expect($rows['count'])->toBeGreaterThan(0);
|
|
expect($rows['allFit'])->toBeTrue();
|
|
$page->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
test('the settings Save button is end-aligned at less than the form\'s width', function (string $url, string $button) {
|
|
$this->actingAs($url === '/admin/settings' ? User::factory()->admin()->create() : User::factory()->create());
|
|
|
|
$page = ready(visit($url)->resize(1280, 800));
|
|
|
|
$metrics = $page->script("(() => {
|
|
const button = document.querySelector('{$button}');
|
|
const form = button.closest('[data-md-form]').getBoundingClientRect();
|
|
const rect = button.getBoundingClientRect();
|
|
return { formWidth: form.width, formRight: form.right, buttonWidth: rect.width, buttonRight: rect.right };
|
|
})()");
|
|
|
|
expect($metrics['buttonWidth'])->toBeLessThan($metrics['formWidth']);
|
|
expect($metrics['buttonRight'])->toEqualWithDelta($metrics['formRight'], 1);
|
|
})->with([
|
|
['/settings/profile', '[data-test="update-profile-button"]'],
|
|
['/admin/settings', '[data-test="save-settings"]'],
|
|
]);
|
|
|
|
test('the admin dashboard heads its shares list with an h2, both in one card', function () {
|
|
$admin = User::factory()->admin()->create();
|
|
Share::factory()->count(2)->create();
|
|
$this->actingAs($admin);
|
|
|
|
$page = ready(visit('/admin/dashboard'));
|
|
|
|
$result = $page->script("(() => {
|
|
const heading = [...document.querySelectorAll('h2')].find((h) => h.textContent.trim() === 'All Shares');
|
|
const list = document.querySelector('[data-test=\"share-row\"]');
|
|
const card = heading?.closest('[data-md-card]');
|
|
return {
|
|
headingIsCardTitle: heading?.matches('[data-md-card-title]') ?? false,
|
|
listInSameCard: !!card && card.contains(list),
|
|
headingBeforeList: !!heading && !!list
|
|
&& !!(heading.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING),
|
|
};
|
|
})()");
|
|
|
|
expect($result['headingIsCardTitle'])->toBeTrue();
|
|
expect($result['listInSameCard'])->toBeTrue();
|
|
expect($result['headingBeforeList'])->toBeTrue();
|
|
});
|
|
|
|
test('the admin settings page has six sections, each a card headed by an h2', function () {
|
|
$this->actingAs(User::factory()->admin()->create());
|
|
|
|
$page = ready(visit('/admin/settings'));
|
|
|
|
$result = $page->script("(() => {
|
|
// Every dialog's own title is an h2 too (components/modal.blade.php), whether open or
|
|
// not: exclude those to count only the page's own section headings.
|
|
const headings = [...document.querySelectorAll('h2:not([data-md-modal-title])')];
|
|
const cards = [...document.querySelectorAll('[data-md-card]')];
|
|
return {
|
|
headings: headings.map((h) => h.textContent.trim()),
|
|
cardCount: cards.length,
|
|
everyCardHeaded: cards.every((card) => card.querySelector('h2[data-md-card-title]') !== null),
|
|
};
|
|
})()");
|
|
|
|
expect($result['headings'])->toBe(['Colour profile', 'Branding', 'Upload Protection', 'Share Passwords', 'Upload Limits', 'Storage']);
|
|
expect($result['cardCount'])->toBe(6);
|
|
expect($result['everyCardHeaded'])->toBeTrue();
|
|
});
|