diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index eabc4984..c8244264 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,7 +63,7 @@ jobs: run: php artisan assets:generate --check - name: Tests - run: ./vendor/bin/pest + run: ./vendor/bin/pest --exclude-group=ssr frontend: runs-on: ubuntu-26.04 @@ -95,11 +95,8 @@ jobs: - name: Build run: npm run build - # Every case behind `requireSsr()` (tests/Support/ssr.php) needs a built SSR - # bundle and a running SSR service, and skips itself when either is missing. - # The `php` job above provides neither, so those cases skip there. This job - # is the one place they execute; REQUIRE_SSR turns those skips into failures - # so they cannot go quiet again. + # The ssr group: tests that need the built bundles, node_modules or a live SSR + # service. REQUIRE_SSR turns their skips into failures here. ssr: runs-on: ubuntu-26.04 @@ -168,18 +165,10 @@ jobs: cat /tmp/ssr.log >&2 || true exit 1 - # The whole suite, not one file. - # - # This once ran only HomepageRenderTest.php, which left every other - # SSR-rendered assertion skipping on every push: the `php` job above - # builds no bundles, so every `ssrHtml()` / `requireSsr()` assertion - # short-circuits there, and this job is the one place they execute. - # - # REQUIRE_SSR turns a skip into a failure, so this cannot go quiet again. - name: SSR render tests env: REQUIRE_SSR: 1 - run: ./vendor/bin/pest + run: ./vendor/bin/pest --group=ssr - name: SSR service log if: always() diff --git a/CLAUDE.md b/CLAUDE.md index 5531a3d6..94701969 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,8 +137,9 @@ Every change needs a test. Feature tests live in `tests/Feature`; there is no `RefreshDatabase` anywhere and there must not be — there is no database. No test reaches the network: `tests/Pest.php` prevents stray HTTP requests, so -fake GitHub with `Http::fake()`. Tests that assert on rendered markup go behind -`requireSsr()` and skip locally without a built SSR bundle. +fake GitHub with `Http::fake()`. SSR is off in tests: a test that asserts on +rendered markup calls `requireSsr()` and sits in `->group('ssr')`, which CI runs +in the job with a live SSR service. It skips locally without one. ## Content diff --git a/app/Support/Seo/LastModified.php b/app/Support/Seo/LastModified.php index 2187b47e..dabdab83 100644 --- a/app/Support/Seo/LastModified.php +++ b/app/Support/Seo/LastModified.php @@ -7,32 +7,16 @@ use Symfony\Component\Process\Process; use Throwable; -/** - * When the files behind a page last changed, for the sitemap's `lastmod`. - * - * Git first: `git log -1 --format=%cI -- ` is the last commit that - * touched any of them. File mtimes are no good on their own, because every - * `git pull` on the server resets them to the pull time, and a sitemap whose - * every `lastmod` moves on each deploy teaches crawlers to ignore the field. - * The newest mtime is the fallback for a checkout without history or files not - * committed yet, and a page with neither gets no `lastmod` at all rather than - * a made-up "now". - * - * A page's sources can include other locales' copy. `forEntry()` keeps only - * what drives the page in the locale asked for: the shared data files plus - * that locale's own content, legal or blog file. - */ +// The sitemap's lastmod: the last commit touching a page's sources. Mtimes are +// only a fallback, since every `git pull` on the server resets them. final class LastModified { - /** - * Seconds `git log` may take before the mtime fallback is used. - */ - private const GIT_TIMEOUT = 2.0; + private const GIT_TIMEOUT = 10.0; /** - * @var array + * @var array|null path => [unix time, ISO 8601] of its last commit */ - private array $memo = []; + private ?array $commits = null; public function __construct( private readonly string $root, @@ -48,29 +32,28 @@ public function forEntry(PageEntry $entry, string $locale): ?CarbonImmutable */ public function forPaths(array $paths): ?CarbonImmutable { - $paths = array_values(array_unique($paths)); - sort($paths); - if ($paths === []) { return null; } - $key = implode("\0", $paths); + $this->commits ??= $this->readGitLog(); + $newest = null; + + foreach (array_unique($paths) as $path) { + $commit = $this->commits[$path] ?? null; - if (array_key_exists($key, $this->memo)) { - return $this->memo[$key]; + if ($commit !== null && ($newest === null || $commit[0] > $newest[0])) { + $newest = $commit; + } } - return $this->memo[$key] = $this->fromGit($paths) ?? $this->fromMtime($paths); + return $newest !== null ? CarbonImmutable::parse($newest[1]) : $this->fromMtime($paths); } /** - * The sources that drive a page in one locale. - * - * A source belongs to a locale when one of its directories is named after - * that locale (`content/vi/…`, `legal/en/…`, `blog/vi/…`). A post directly - * under `resources/blog/` belongs to the default locale. Anything else is - * shared data and drives every locale. + * The sources that drive a page in one locale: shared data, plus the files + * under a directory named after that locale. A post directly under + * `resources/blog/` belongs to the default locale. * * @param list $sources * @return list @@ -97,20 +80,40 @@ private static function localeOf(string $source): ?string } /** - * @param list $paths + * @return array */ - private function fromGit(array $paths): ?CarbonImmutable + private function readGitLog(): array { try { - $process = new Process(['git', 'log', '-1', '--format=%cI', '--', ...$paths], $this->root, null, null, self::GIT_TIMEOUT); + $process = new Process( + ['git', '-c', 'core.quotePath=false', 'log', '--format=%x00%ct %cI', '--name-only', '--relative'], + $this->root, + null, + null, + self::GIT_TIMEOUT, + ); $process->run(); + } catch (Throwable) { + return []; + } - $output = trim($process->getOutput()); + if (! $process->isSuccessful()) { + return []; + } - return $process->isSuccessful() && $output !== '' ? CarbonImmutable::parse($output) : null; - } catch (Throwable) { - return null; + $commits = []; + $commit = null; + + foreach (explode("\n", $process->getOutput()) as $line) { + if (str_starts_with($line, "\0")) { + [$time, $iso] = explode(' ', substr($line, 1), 2); + $commit = [(int) $time, $iso]; + } elseif ($line !== '' && $commit !== null && (! isset($commits[$line]) || $commit[0] > $commits[$line][0])) { + $commits[$line] = $commit; + } } + + return $commits; } /** diff --git a/phpunit.xml b/phpunit.xml index ae9923a6..4d7e2403 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,6 +18,7 @@ + diff --git a/tests/Feature/Assets/AssetManifestTest.php b/tests/Feature/Assets/AssetManifestTest.php index aa179203..547f9943 100644 --- a/tests/Feature/Assets/AssetManifestTest.php +++ b/tests/Feature/Assets/AssetManifestTest.php @@ -5,31 +5,18 @@ use App\Support\Localization\Locales; use Dom\HTMLDocument; use Illuminate\Http\Request; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Route; use PHPUnit\Framework\Assert; use Symfony\Component\HttpKernel\Exception\HttpException; -/** - * resources/data/assets.json, the public site's one image manifest - * (architecture §1.9, design-system §6). - * - * Every editorial image is a placeholder until the owner supplies it, and the - * only switch is an entry's `status`. So the manifest has to be right on its - * own: every handoff field the spec asks for (§9.1), the geometry the visual - * contract pins, short NFC descriptions, real pages, and, once an entry is - * supplied, every promised file on disk at its true size and within budget. - * - * The supplied path has no real art yet, so it runs against an isolated - * fixture, tests/Fixtures/assets/manifest.json, with tiny images drawn into a - * temporary directory. The same fixture holds the JavaScript renderer to the - * same file names (tests/js/asset-slot.test.ts). - */ function assetManifestPath(): string { return dirname(__DIR__, 3) . '/resources/data/assets.json'; } +// Shared with tests/js/asset-slot.test.ts, which holds the renderer to the same file names. function assetFixturePath(): string { return dirname(__DIR__, 2) . '/Fixtures/assets/manifest.json'; @@ -43,9 +30,6 @@ function assetManifestData(string $path): array return json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); } -/** - * A scratch directory for one test, removed again at teardown. - */ function assetScratchDirectory(): string { $dir = sys_get_temp_dir() . '/asset-manifest-' . bin2hex(random_bytes(6)); @@ -56,9 +40,6 @@ function assetScratchDirectory(): string } /** - * Remembers a scratch directory, or with no argument hands back every one - * remembered so far and forgets them. - * * @return list */ function assetScratchDirectories(?string $add = null): array @@ -94,9 +75,6 @@ function assetRemoveDirectory(string $dir): void rmdir($dir); } -/** - * Draws one real image file of the given size and format. - */ function assetDrawImage(string $path, int $width, int $height, string $format): void { if (! is_dir(dirname($path))) { @@ -119,10 +97,6 @@ function assetDrawImage(string $path, int $width, int $height, string $format): }; } -/** - * Installs every file the supplied fixture entries promise into `$root/public`, - * then returns a manifest that reads them. - */ function assetSuppliedFixture(string $root, ?callable $mutate = null): AssetManifest { $data = assetManifestData(assetFixturePath()); @@ -151,6 +125,33 @@ function assetInstallFiles(AssetManifest $manifest): void } } +function assetInstalledFixture(string $root): AssetManifest +{ + // Encoding the AVIFs is the slow part, so they are drawn once per process and copied. + static $template = null; + + if ($template === null) { + $template = sys_get_temp_dir() . '/asset-manifest-template-' . bin2hex(random_bytes(6)); + mkdir($template, 0755, true); + assetInstallFiles(assetSuppliedFixture($template)); + register_shutdown_function(static fn() => assetRemoveDirectory($template)); + } + + File::copyDirectory("{$template}/public", "{$root}/public"); + + return assetSuppliedFixture($root); +} + +/** + * @return array> + */ +function assetReferences(): array +{ + static $references = null; + + return $references ??= (new AssetReferences())->all(); +} + /** * @return list */ @@ -204,10 +205,7 @@ function assetSlotFamilies(): array } }); - /* - * Pinned on purpose. These numbers are the visual contract: changing one - * means editing design-system §6.3 and this test in the same change. - */ + // The visual contract: a change here changes design-system §6.3 too. it('pins the geometry of the screenshot kinds', function (): void { $kinds = (new AssetManifest())->kinds(); $pick = fn(string $kind): array => [ @@ -310,6 +308,7 @@ function assetSlotFamilies(): array $assets = (new AssetManifest())->assets(); $limits = array_fill_keys(Locales::codes(), 200); $limits['en'] = 160; + $offences = []; foreach ($assets as $id => $entry) { foreach ($limits as $locale => $limit) { @@ -319,31 +318,31 @@ function assetSlotFamilies(): array continue; } - Assert::assertLessThanOrEqual($limit, mb_strlen($text), "{$id}: description.{$locale} is " . mb_strlen($text) . " characters, over {$limit}"); + if (mb_strlen($text) > $limit) { + $offences[] = "{$id}: description.{$locale} is " . mb_strlen($text) . " characters, over {$limit}"; + } foreach (array_keys($assets) as $other) { - Assert::assertStringNotContainsString($other, $text, "{$id}: description.{$locale} names the id {$other}"); + if (str_contains($text, $other)) { + $offences[] = "{$id}: description.{$locale} names the id {$other}"; + } } } foreach (['description', 'alt', 'caption'] as $field) { foreach ($entry[$field] ?? [] as $locale => $text) { - if ($text !== null) { - Assert::assertTrue(Normalizer::isNormalized($text, Normalizer::FORM_C), "{$id}: {$field}.{$locale} is not NFC"); + if ($text !== null && ! Normalizer::isNormalized($text, Normalizer::FORM_C)) { + $offences[] = "{$id}: {$field}.{$locale} is not NFC"; } } } } + + expect($assets)->not->toBeEmpty() + ->and($offences)->toBe([]); }); - /* - * The Vietnamese briefs and alt text are read by Vietnamese developers, - * who read "bảng" as a database table: the glossary keeps "table" in - * English for that reason (positioning §11.2), so a sheet is a "hộp - * thoại" and a panel a "khung". "Được giữ lại" means "kept", the opposite - * of a statement held back from a script, and "của nó" / "cho chúng" are - * English possessives carried over word for word. - */ + // A Vietnamese developer reads "bảng" as a database table (positioning §11.2). it('keeps the Vietnamese text clear of the misreadings found in review', function (): void { $misreadings = [ '/\bbảng\b/iu' => '"bảng" for a sheet or panel', @@ -396,13 +395,6 @@ function assetSlotFamilies(): array } }); - /* - * A window always has a phone crop (design-system §6.3). A detail, an iPad - * capture or an illustration may have one too: at 343 px wide a detail is - * about half size and an iPad capture about a quarter, so one whose focal - * text must read on a phone names a crop (found in review). Other kinds - * already render at a legible size on a phone, or are the crop itself. - */ it('gives every window a phone crop, and any other crop to a kind that shrinks on a phone', function (): void { $assets = (new AssetManifest())->assets(); $crops = []; @@ -416,7 +408,7 @@ function assetSlotFamilies(): array $crop = $entry['mobile']; - /* A page's lead or section image (P1, P2) is the one a reader must be able to read. */ + // At 343 px a detail is about half size and an iPad capture a quarter (found in review). if (in_array($entry['kind'], ['detail', 'ipad'], true) && in_array($entry['handoffPriority'], ['P1', 'P2'], true)) { Assert::assertIsString($crop, "{$id}: a {$entry['handoffPriority']} {$entry['kind']} shrinks to 343 px on a phone and needs a phone crop"); } @@ -488,7 +480,7 @@ function assetSlotFamilies(): array $assets = (new AssetManifest())->assets(); $unknown = []; - foreach ((new AssetReferences())->all() as $id => $paths) { + foreach (assetReferences() as $id => $paths) { if (! array_key_exists($id, $assets) || ! $assets[$id]['slot']) { $unknown[] = "{$id} (" . implode(', ', $paths) . ')'; } @@ -524,17 +516,12 @@ function assetSlotFamilies(): array ]); }); - /* - * Every family places its slots now, so a family that places none is a - * failure, not a skip: a renamed key or a moved page would otherwise turn - * this check into a silent green. - */ it('places every slot of a family once the family renders any', function (string $family): void { $assets = (new AssetManifest())->assets(); - $references = (new AssetReferences())->all(); + $references = assetReferences(); $placed = array_keys($references); - /* A window's slot renders its phone crop too. */ + // A window's slot renders its phone crop too. foreach ($assets as $id => $entry) { if ($entry['mobile'] !== null && in_array($id, $placed, true)) { $placed[] = $entry['mobile']; @@ -552,9 +539,7 @@ function assetSlotFamilies(): array describe('the supplied path, on an isolated fixture', function (): void { it('accepts a complete set of files', function (): void { - $root = assetScratchDirectory(); - $manifest = assetSuppliedFixture($root); - assetInstallFiles($manifest); + $manifest = assetInstalledFixture(assetScratchDirectory()); expect($manifest->expectedFiles('fixture-hero'))->toHaveCount(8); expect($manifest->problems())->toBe([]); @@ -572,7 +557,7 @@ function assetSlotFamilies(): array it('reports each way a supplied entry can be wrong', function (callable $break, string $expected): void { $root = assetScratchDirectory(); - assetInstallFiles(assetSuppliedFixture($root)); + assetInstalledFixture($root); $manifest = $break($root); $problems = implode("\n", $manifest->problems()); @@ -662,12 +647,7 @@ function (string $root): AssetManifest { ], ]); - /* - * A window and its phone crop render as one art-directed : the - * window behind ``, the crop as the - * below it. The preload has to follow the same split, or a phone - * downloads the window it never paints and loads the crop it shows late. - */ + // Otherwise a phone downloads the window it never paints and loads its crop late. it('preloads the window above 768px and its phone crop below, per theme', function (): void { $manifest = new AssetManifest(assetFixturePath()); @@ -714,13 +694,6 @@ function (string $root): AssetManifest { ]); }); - /* - * The bundle imports geometry and selected text, excluding the owner's - * handoff fields (architecture §1.4 asks a shared-chunk addition to stay - * near 10 KB gzipped). A manifest edit without `php artisan assets:generate` - * would leave the site rendering the old state, so a stale slice fails - * here. - */ it('bundles a current slice of the manifest, with only what rendering needs', function (): void { $manifest = new AssetManifest(); $committed = resource_path('js/lib/data/asset-slots.json'); @@ -737,11 +710,6 @@ function (string $root): AssetManifest { expect(array_keys($entry))->toBe(['kind', 'type', 'slot', 'aspect', 'priority', 'theme', 'locale', 'mobile', 'status', 'src', 'replacement'], $id); } - /* - * Each page downloads shared geometry and only its active language's - * rendering text. Keep the existing ceiling as languages are added, - * including supplied alt text and captions; owner fields stay out. - */ $minified = json_encode($projection, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); $metadataBytes = strlen((string) gzencode($minified, 9)); @@ -795,11 +763,7 @@ function (string $root): AssetManifest { }); it('preloads a supplied image that a page places with priority, never a placeholder', function (): void { - /* - * The /ios header renders `ipad-table-browse` with `` - * although its entry loads lazily elsewhere; its controller asks for the - * preload the same way. - */ + // /ios places ipad-table-browse with although its entry loads lazily elsewhere. $fixture = new AssetManifest(assetFixturePath()); $detail = $fixture->lcpDescriptor('fixture-detail', 'en', priority: true); @@ -816,17 +780,13 @@ function (string $root): AssetManifest { expect($manifest->ogCard('fixture-og', 'vi'))->toBeNull(); - assetInstallFiles($manifest); + assetInstalledFixture($root); expect($manifest->ogCard('fixture-og', 'vi'))->toBe('/og/fixture/fixture-og-vi.png'); expect($manifest->ogCard('fixture-og', 'en'))->toBe('/og/fixture/fixture-og-en.png'); expect($manifest->ogCard('fixture-hero', 'en'))->toBeNull(); - /* - * The real card: offered exactly when the committed entry is - * supplied, since its files are committed with it - * (Seo/BespokeOgCardTest). - */ + // The real card's files are committed with its entry (Seo/BespokeOgCardTest). $real = new AssetManifest(); foreach (['en', 'vi'] as $locale) { @@ -836,11 +796,6 @@ function (string $root): AssetManifest { }); }); -/* - * End to end: what a visitor's browser receives. The component tests prove - * the renderer never emits an image request for a placeholder; this proves - * no page wraps one around it. - */ it('serves no image request inside a placeholder on any page', function (): void { requireSsr(); Http::fake([ @@ -882,6 +837,6 @@ function (string $root): AssetManifest { } } - /* Under REQUIRE_SSR a skip here would be green; finding nothing means the selector or the markup changed. */ + // Finding nothing means the selector or the markup changed. Assert::assertGreaterThan(0, $found, 'No page renders an AssetSlot placeholder'); -}); +})->group('ssr'); diff --git a/tests/Feature/Assets/EmptySlotsTest.php b/tests/Feature/Assets/EmptySlotsTest.php index 57ffe055..01ce7506 100644 --- a/tests/Feature/Assets/EmptySlotsTest.php +++ b/tests/Feature/Assets/EmptySlotsTest.php @@ -1,7 +1,6 @@ > page path => the slots it places that have no image + * @return array> */ function emptySlotPages(): array { @@ -57,7 +56,7 @@ function emptySlotPages(): array $checked = 0; foreach (emptySlotPages() as $page => $ids) { - foreach (Locales::codes() as $locale) { + foreach (['en', 'vi'] as $locale) { $path = LocalizedUrl::path($page, $locale); $response = get($path); @@ -104,4 +103,4 @@ function emptySlotPages(): array } expect($checked)->toBeGreaterThanOrEqual(count(emptySlotPages())); -}); +})->group('ssr'); diff --git a/tests/Feature/Chrome/IconsTest.php b/tests/Feature/Chrome/IconsTest.php index cc581c51..776eac2d 100644 --- a/tests/Feature/Chrome/IconsTest.php +++ b/tests/Feature/Chrome/IconsTest.php @@ -2,18 +2,9 @@ use PHPUnit\Framework\Assert; -/* - * The tab icon, the home-screen icon and the web manifest's icon are fetched on - * a reader's first visit, ahead of anything they came to read. The logo was a - * 156 KB, 16-bit PNG serving all three; each is now an 8-bit PNG well under - * 25 KB, at the size the page declares for it. - */ - +// The logo was one 156 KB, 16-bit PNG serving the tab, home-screen and manifest icons. const ICON_BUDGET_BYTES = 25 * 1024; -/** - * Asserts a public PNG exists at the declared size and within the budget. - */ function expectSmallIcon(string $href, int $size): void { $file = public_path(ltrim($href, '/')); diff --git a/tests/Feature/Chrome/LayoutRegressionTest.php b/tests/Feature/Chrome/LayoutRegressionTest.php index 608dea50..42a1d0e1 100644 --- a/tests/Feature/Chrome/LayoutRegressionTest.php +++ b/tests/Feature/Chrome/LayoutRegressionTest.php @@ -2,36 +2,13 @@ use PHPUnit\Framework\Assert; -/* - * Layout defects that screenshots and probes found during the integration - * checks, each held by the cheapest check that fails when it comes back. Most - * are source assertions, because a component's class list is what decides them - * and the suite renders no browser layout; the server-rendered ones read SSR - * markup. - */ - function layoutSource(string $path): string { return (string) file_get_contents(resource_path($path)); } -it('keeps a table\'s hidden words inside its own scroll region', function (): void { - /* - * `sr-only` is `position: absolute`. With a static region, the hidden - * "Included" / "Not available" words of a column scrolled out of view - * escaped the overflow clip and widened the document to 532px at 375. - */ - $table = layoutSource('js/components/ui/data-table.tsx'); - - expect($table)->toMatch("/role=\"region\"[^>]*className=\\{cn\\('relative overflow-x-auto/"); -}); - it('lets no table force a phone-width scroll: minimum widths start at 640px or wider', function (): void { - /* - * Tables fold their secondary columns into the first cell below 640px - * (design-system §5.3.10) instead of keeping a minimum width that pushed - * the competitor column, the plan columns or the notes off screen. - */ + // A minimum width pushed the competitor, plan and notes columns off a phone screen. $files = array_merge(glob(resource_path('js/components/**/*.tsx')), glob(resource_path('js/pages/**/*.tsx')), glob(resource_path('js/pages/*.tsx'))); $checked = 0; @@ -65,30 +42,19 @@ function layoutSource(string $path): string }); it('puts every section on the page grid\'s left edge, whatever its measure', function (): void { - /* - * A `text` section used to be a centred 704px Container, so its heading - * jumped 256px right of the H1 and the sections around it (§3.4, §4.2). - */ + // A centred 704px `text` section put its heading 256px right of the H1. $section = layoutSource('js/components/ui/section.tsx'); - expect($section)->toContain('') - ->not->toContain('') + expect($section)->not->toContain('') ->toContain("text: 'max-w-[44rem]'"); expect(layoutSource('js/pages/Pricing.tsx'))->not->toContain('not->toContain('width="text"'); }); it('spaces neighbouring sections one rhythm apart, with the join in the middle', function (): void { - /* - * Every section pads half the rhythm on each side, so two neighbours are - * one rhythm apart and the page frame's join (app.css) has the same air - * above and below it. The old collapse rule took the top padding away from - * the second section, which would put the join flush on its heading. - */ + // The old collapse rule took the second section's top padding and put the join flush on its heading. expect(layoutSource('js/components/ui/section.tsx'))->toContain("'py-8 md:py-10 xl:py-12'") - ->toContain("flush && 'pb-0 md:pb-0 xl:pb-0'") ->not->toContain('data-rhythm'); expect(layoutSource('css/app.css'))->not->toContain('data-rhythm'); }); @@ -96,8 +62,7 @@ function layoutSource(string $path): string it('keeps a link\'s arrow on the line of its last word', function (): void { expect(layoutSource('js/components/ui/text-link.tsx'))->toMatch("/standalone:\\s*'inline text-sm/") ->not->toContain("standalone:\n 'inline-flex"); - expect(layoutSource('js/components/site/site-footer.tsx'))->toContain("'type-small inline-block py-[5px]") - ->not->toContain("'inline-flex min-h-8 items-center gap-1"); + expect(layoutSource('js/components/site/site-footer.tsx'))->not->toContain("'inline-flex min-h-8 items-center gap-1"); }); it('draws shortcut glyphs in a face that has them, as an inline box', function (): void { @@ -109,11 +74,7 @@ function layoutSource(string $path): string }); it('draws the phone breadcrumb link as a block of its own', function (): void { - /* - * Below 640px the trail is one link on its own line. As inline content of - * the nav it read as a link inside the hidden trail's text, which a - * contrast check then compared it with: 2.28:1 in the dark theme. - */ + // Inline, the phone link was contrast-checked against the hidden trail's text: 2.28:1 in the dark theme. expect(layoutSource('js/components/ui/breadcrumbs.tsx'))->toContain('className="flex min-h-8 w-fit items-center gap-1.5') ->not->toContain('className="inline-flex min-h-8'); }); @@ -133,15 +94,7 @@ function layoutSource(string $path): string }); it('opens the Features panel on mouse hover, and only for a mouse', function (): void { - /* - * The owner asked for the panel to open on hover rather than only on a - * click. A short delay each way keeps a pointer crossing the nav from - * opening it and a curved path into the panel from closing it. Touch and - * pen send pointer events too, and opening on them would fight the tap - * that follows, so they keep the click. A click on a panel that hover - * opened pins it open instead of closing it under the pointer. Checked in - * a browser: hover, leave, click after hover, keyboard and touch. - */ + // Touch and pen send pointer events too, and opening on them would fight the tap that follows. $menu = layoutSource('js/components/site/features-menu.tsx'); expect($menu)->toContain('onPointerEnter={onPointerEnter}') @@ -154,12 +107,7 @@ function layoutSource(string $path): string }); it('draws the App Store badge first on an iPhone or iPad, and keeps Mac first in the markup', function (): void { - /* - * Positioning §3.2: both actions are rendered Mac first on every device, - * and a client may promote the badge on an iPhone or iPad. The promotion - * is CSS order under the `ios` class the root template sets in the head, - * so the two actions never swap under a finger after the page is shown. - */ + // CSS order under the head's `ios` class, so the two actions never swap under a finger after paint. $pair = layoutSource('js/components/download/action-pair.tsx'); $menu = layoutSource('js/components/site/mobile-nav.tsx'); $template = layoutSource('views/app.blade.php'); @@ -168,7 +116,6 @@ function layoutSource(string $path): string Assert::assertLessThan(strpos($pair, '{iosAction}'), strpos($pair, '{mac}'), 'The Mac action comes first in the markup'); expect($menu)->toContain('
'); - Assert::assertLessThan(strpos($menu, '#s', $html, $section); - expect($section[0] ?? '')->toContain('class="relative overflow-x-auto') + // In a static region the sr-only cell words escaped the overflow clip and widened the page to 532px at 375. + expect($section[0] ?? '')->toMatch('/
]*class="relative overflow-x-auto/') ->not->toContain('min-w-[36rem]'); -}); +})->group('ssr'); it('does not repeat the contact line under a document that has its own Contact section', function (string $path, bool $repeats): void { // The markup only: the catalog line also travels in the page props. @@ -200,14 +148,10 @@ function layoutSource(string $path): string ['/vi/privacy', false], ['/terms', false], ['/refund-policy', true], -]); +])->group('ssr'); it('keeps a busy button focusable, so the reader who pressed it keeps their place', function (): void { - /* - * `loading` set the native `disabled`, which drops focus to mid - * request (WCAG 2.4.3). A busy button is `aria-disabled` and swallows the - * click, a submit included; the email field is read-only, not disabled. - */ + // A native `disabled` dropped focus to mid request (WCAG 2.4.3). $button = layoutSource('js/components/ui/button.tsx'); expect($button)->toContain('disabled={disabled}') @@ -222,12 +166,7 @@ function layoutSource(string $path): string }); it('keeps the billing-cycle control with the prices it changes while the plan cards are stacked', function (): void { - /* - * At 390x844 the control sat 1,200px above the Team price, so a tap - * changed nothing on screen but the caption. Below 1024px its row pins - * under the header; the caption stays in the flow, and a short viewport - * keeps the row there too. - */ + // At 390x844 the control sat 1,200px above the Team price, so a tap changed nothing on screen. $control = layoutSource('js/components/pricing/billing-cycle-control.tsx'); expect($control)->toContain('max-lg:[@media(min-height:40rem)]:sticky') @@ -244,11 +183,7 @@ function layoutSource(string $path): string }); it('totals the seats as they are typed, and says when a typed count was changed', function (): void { - /* - * The shared Stepper reports a typed count on blur or Enter, so the card - * showed "5 seats" beside a field that read 50, and turned 3 into 5 or - * 999 into 200 without a word. - */ + // The card showed "5 seats" beside a field that read 50, and clamped 3 to 5 without a word. $card = layoutSource('js/components/pricing/pricing-card.tsx'); expect($card)->toContain('onChange={follow}') diff --git a/tests/Feature/Chrome/PageChunkTest.php b/tests/Feature/Chrome/PageChunkTest.php index 4448fc0d..e5f55111 100644 --- a/tests/Feature/Chrome/PageChunkTest.php +++ b/tests/Feature/Chrome/PageChunkTest.php @@ -5,25 +5,11 @@ use Inertia\Inertia; use PHPUnit\Framework\Assert; -/* - * Pages are lazy chunks (`@inertiajs/vite` resolves them with a non-eager - * glob), so the browser used to discover a page's code only after app.tsx had - * run: one more round trip before hydration on every first visit. The root - * template names the page's own file as a `@vite` entry, which sends its - * modulepreload, and those of everything it imports, with the document. - * - * Every component a registry page renders has a file (SeoSmokeTest), and so - * does the error page. These tests hold the template to that, and, once the - * client bundle is built, check the manifest has a key for every page file. - */ - -/** - * The client manifest, or a skip (a failure under REQUIRE_SSR) without a build. - * - * @return array> - */ +/** @return array> */ function clientManifest(): array { + requireSsrJob(); + $path = public_path('build/manifest.json'); if (! is_file($path)) { @@ -33,20 +19,8 @@ function clientManifest(): array return json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); } -it('names the page component as a @vite entry, only when its file exists', function (): void { - $blade = (string) file_get_contents(resource_path('views/app.blade.php')); - - expect($blade)->toContain("@php(\$pageEntry = 'resources/js/pages/' . (\$page['component'] ?? '') . '.tsx')") - ->toContain("@vite(array_values(array_filter(['resources/css/app.css', 'resources/js/app.tsx', is_file(base_path(\$pageEntry)) ? \$pageEntry : null, is_file(base_path(\$catalogEntry)) ? \$catalogEntry : null])))"); -}); - -/* - * The entry chunk carried the UI catalog of every language, twelve copies of - * the same strings for a reader who needs one. English stays in the entry; - * each other language is a chunk the page resolver awaits, and the root - * template names it as a `@vite` entry so it arrives with the document. - */ it('imports only the default language\'s UI catalog statically, and awaits the page\'s own', function (): void { + // The entry chunk once carried twelve copies of the UI strings. $index = (string) file_get_contents(resource_path('js/i18n/index.ts')); preg_match_all("#^import \\w+ from './messages/([^/]+)/index\\.ts';#m", $index, $static); @@ -68,7 +42,7 @@ function clientManifest(): array Assert::assertContains($catalog, $entry['dynamicImports'] ?? [], "app.tsx does not load {$catalog} on demand"); Assert::assertNotContains($catalog, $entry['imports'] ?? [], "app.tsx imports {$catalog} statically"); } -}); +})->group('ssr'); it('sends a translated page its own language\'s catalog with the document, and no other', function (string $path, string $locale): void { $manifest = clientManifest(); @@ -87,13 +61,7 @@ function clientManifest(): array ['/vi/download', 'vi'], ['/pt-BR/pricing', 'pt-BR'], ['/zh-Hans', 'zh-Hans'], -]); - -it('renders the error page from a file that exists', function (): void { - $this->get('/no-such-page')->assertNotFound(); - - Assert::assertFileExists(resource_path('js/pages/Error.tsx')); -}); +])->group('ssr'); it('has a manifest key for every page file', function (): void { $manifest = clientManifest(); @@ -108,7 +76,7 @@ function clientManifest(): array foreach ($pages as $page) { Assert::assertArrayHasKey($page, $manifest, "{$page} has no entry in public/build/manifest.json"); } -}); +})->group('ssr'); it('sends the page chunk and its imports with the document', function (string $path, string $component): void { $manifest = clientManifest(); @@ -127,7 +95,6 @@ function clientManifest(): array expect($html)->toMatch($preload($manifest[$import]['file'])); } - // The app's entry still runs first, so the app boots as before. preg_match($script($manifest['resources/js/app.tsx']['file']), $html, $entry, PREG_OFFSET_CAPTURE); preg_match($script($chunk['file']), $html, $page, PREG_OFFSET_CAPTURE); @@ -137,7 +104,7 @@ function clientManifest(): array ['/pricing', 'Pricing'], ['/vi/download', 'Download'], ['/blog/tablepro-0-77', 'Blog/Post'], -]); +])->group('ssr'); it('renders a component with no file without asking the manifest for it', function (): void { clientManifest(); @@ -150,4 +117,4 @@ function clientManifest(): array expect($html)->not->toContain('Nope/Missing.tsx') ->toContain('"component":"Nope\/Missing"'); -}); +})->group('ssr'); diff --git a/tests/Feature/Chrome/PageFrameTest.php b/tests/Feature/Chrome/PageFrameTest.php index 6ca69312..afa75c76 100644 --- a/tests/Feature/Chrome/PageFrameTest.php +++ b/tests/Feature/Chrome/PageFrameTest.php @@ -2,22 +2,6 @@ use PHPUnit\Framework\Assert; -/* - * The page frame (design-system §4.7): two rails on the wide Container's outer - * edge from 1280px, a full-bleed join between every two blocks of `
`, - * a mark wherever a join meets a rail, and cell grids whose outer lines land - * on the rails. - * - * The geometry was measured in a browser when the frame landed (390 to - * 1920px, every template): rails on the Container edge, cell lines on the - * rails, no horizontal scroll. The suite renders no layout, so these tests - * hold the markup and the CSS those measurements depend on. The ways it can - * go wrong without a typecheck noticing: a template whose block is a narrow - * Container, so its join stops short of the screen edge; a block that brings - * its own rule and doubles the join; a cell grid inside a narrow column, - * whose right edge then draws a line through the middle of the page. - */ - /** @return array */ function frameTemplates(): array { @@ -48,8 +32,8 @@ function frameCss(): string return (string) file_get_contents(resource_path('css/frame.css')); } -it('draws the rails once over the page and once in the header, as decoration only', function (): void { - $xpath = frameXPath(ssrHtml('/')); +function frameAssertRails(DOMXPath $xpath): void +{ $rails = $xpath->query('//*[@data-frame-rails]'); expect($rails->length)->toBe(2); @@ -60,17 +44,52 @@ function frameCss(): string expect(frameClasses($rail))->toContain('hidden', 'xl:block', 'print:hidden', 'forced-colors:hidden', 'pointer-events-none'); } - /* - * The rails sit on the box `Container` fills from 1280px: 76rem of content - * and a 2rem gutter on each side. If either number moves alone, the rails - * detach from the content, which is how the previous frame failed above - * about 1616px. - */ + // 80rem is the 76rem Container plus its 2rem gutters; the previous frame drifted off the content above 1616px. expect((string) file_get_contents(resource_path('js/components/shared/frame-rails.tsx')))->toContain('max-w-[80rem]'); expect((string) file_get_contents(resource_path('js/components/ui/container.tsx')))->toContain("wide: 'max-w-[76rem]'")->toContain('lg:px-8'); -}); +} + +function frameAssertHomeCells(DOMXPath $xpath, string $path): void +{ + $cells = fn(string $id): int => $xpath->query("//section[@id=\"{$id}\"]//*[contains(concat(' ', normalize-space(@class), ' '), ' cell-grid ')]")->length; + + foreach (['top', 'databases', 'sponsors', 'features', 'safety', 'platforms', 'pricing', 'open-source'] as $id) { + expect($cells($id))->toBe(1, "{$path}: #{$id} is a set of like items, drawn as cells"); + } + + foreach (['ai', 'switch'] as $id) { + expect($cells($id))->toBe(0, "{$path}: #{$id} is prose and stays unlined"); + } + + // A section that ends in cells closes them on the next join, not 48px above it. + foreach (['sponsors', 'features', 'safety', 'platforms', 'open-source'] as $id) { + expect(frameClasses($xpath->query("//section[@id=\"{$id}\"]")->item(0)))->toContain('pb-0', 'md:pb-0', 'xl:pb-0'); + } +} + +function frameAssertFaqTopics(DOMXPath $xpath): void +{ + $topics = $xpath->query('//main//section[@aria-labelledby]/*[contains(concat(" ", normalize-space(@class), " "), " cell-grid ")]'); + + expect($topics->length)->toBeGreaterThan(1); + + foreach ($topics as $grid) { + expect($xpath->query('./*', $grid)->length)->toBe(2, 'A topic is its heading cell and its questions cell'); + expect($xpath->query('.//*[@data-rule-list]', $grid)->item(0)?->getAttribute('class'))->toContain('cell-rows'); + } +} + +function frameAssertBlogLists(DOMXPath $xpath): void +{ + foreach (['guides', 'releases'] as $id) { + $list = $xpath->query("//main/section[@id=\"{$id}\"]//ol[@data-rule-list]")->item(0); + + expect($list)->not->toBeNull("#{$id} has no ruled list"); + expect(frameClasses($list))->toContain('frame-rows'); + } +} -it('joins every two blocks of main with one full-bleed rule', function (string $path): void { +it('frames every template: one join per block, each marked, and every grid and rule reaching the rails', function (string $path): void { $xpath = frameXPath(ssrHtml($path)); $blocks = iterator_to_array($xpath->query('//main/*')); @@ -79,18 +98,73 @@ function frameCss(): string foreach ($blocks as $index => $block) { $name = $block->nodeName . ($block->getAttribute('id') !== '' ? '#' . $block->getAttribute('id') : '') . " (block {$index})"; - /* - * The join is the block's top border, so a block narrower than the - * page draws a join that stops short of the screen edge. A narrow - * Container goes inside a full-width block, never at the top level. - */ foreach (frameClasses($block) as $class) { Assert::assertStringStartsNotWith('max-w-', $class, "{$path}: {$name} is narrower than the page, so its join stops short"); Assert::assertNotSame('mx-auto', $class, "{$path}: {$name} is a centred box, so its join stops short"); Assert::assertDoesNotMatchRegularExpression('/^(?:[a-z]+:)*border-[tby](?:-|$)/', $class, "{$path}: {$name} draws its own rule beside the frame's join"); } } -})->with(frameTemplates()); + + // A per-section switch once marked one join out of nine. + expect($xpath->query('//footer[@data-join-mark]')->length)->toBe(1, "{$path}: the footer's rule is the last join and is marked like the others"); + expect($xpath->query('//main//*[@data-join-mark]')->length)->toBe(0, "{$path}: joins in
are marked by position, not by a switch"); + + foreach ($xpath->query('//*[contains(concat(" ", normalize-space(@class), " "), " cell-grid ")]') as $grid) { + for ($node = $grid->parentNode; $node instanceof DOMElement && $node->nodeName !== 'main'; $node = $node->parentNode) { + foreach (frameClasses($node) as $class) { + if (str_starts_with($class, 'max-w-[') && $class !== 'max-w-[76rem]') { + Assert::fail("{$path}: a cell grid sits inside a {$class} column, so its right edge draws a line through the page"); + } + } + } + } + + // Rules that stopped 32px short of the rails read as loose lines in open space. + $reaches = ['frame-rows', 'frame-rows-text', 'cell-rows', 'frame-table']; + $ruled = $xpath->query('//main//dl | //main//*[@data-rule-list] | //main//*[@role="region"][table]'); + + foreach ($ruled as $element) { + if (array_intersect($reaches, frameClasses($element)) !== []) { + continue; + } + + for ($node = $element->parentNode; $node instanceof DOMElement; $node = $node->parentNode) { + $classes = frameClasses($node); + + if (in_array('rounded-panel', $classes, true) || in_array('blog-article', $classes, true)) { + continue 2; + } + } + + Assert::fail("{$path}: a <{$element->nodeName}> with rules stops short of the frame; give it frame-rows, frame-rows-text, cell-rows or frame-table"); + } + + $ownBlocks = [ + '/pricing' => ['license', 'billing', 'refunds', 'team', 'open-source', 'faq'], + '/download' => ['install', 'updates', 'older-versions'], + '/blog/tablepro-0-77' => ['related'], + ]; + + foreach ($ownBlocks[$path] ?? [] as $id) { + expect($xpath->query("//main/section[@id=\"{$id}\"]")->length)->toBe(1, "{$path}: #{$id} is a block of its own, so the frame's join separates it"); + } + + if ($path === '/') { + frameAssertRails($xpath); + } + + if ($path === '/' || $path === '/vi') { + frameAssertHomeCells($xpath, $path); + } + + if ($path === '/faq') { + frameAssertFaqTopics($xpath); + } + + if ($path === '/blog') { + frameAssertBlogLists($xpath); + } +})->with(frameTemplates())->group('ssr'); it('draws the join in CSS, by position, and lands anchors behind the header\'s rule', function (): void { expect(frameCss())->toMatch('/main > \* \+ \* \{\s*border-top: 1px solid var\(--rule\);/') @@ -98,19 +172,6 @@ function frameCss(): string ->and((string) file_get_contents(resource_path('css/app.css')))->toContain('scroll-padding-top: 5rem;'); }); -it('marks every join by position, so no page picks which joins get one', function (string $path): void { - /* - * The rule a reader can see: a line that crosses the whole page is marked - * where it crosses the frame, and a line inside the frame never is. A - * per-section switch once marked one join out of nine, which read as an - * omission everywhere else. - */ - $xpath = frameXPath(ssrHtml($path)); - - expect($xpath->query('//footer[@data-join-mark]')->length)->toBe(1, "{$path}: the footer's rule is the last join and is marked like the others"); - expect($xpath->query('//main//*[@data-join-mark]')->length)->toBe(0, "{$path}: joins in
are marked by position, not by a switch"); -})->with(frameTemplates()); - it('shows marks only where a 1280px frame leaves them room, and never in forced colours or print', function (): void { $css = frameCss(); $start = (int) strpos($css, '@media (min-width: 82rem)'); @@ -127,22 +188,6 @@ function frameCss(): string expect($css)->toContain('@media (forced-colors: active), print {'); }); -it('puts cell grids only where their lines can reach the rails', function (string $path): void { - $xpath = frameXPath(ssrHtml($path)); - - foreach ($xpath->query('//*[contains(concat(" ", normalize-space(@class), " "), " cell-grid ")]') as $grid) { - for ($node = $grid->parentNode; $node instanceof DOMElement && $node->nodeName !== 'main'; $node = $node->parentNode) { - foreach (frameClasses($node) as $class) { - if (str_starts_with($class, 'max-w-[') && $class !== 'max-w-[76rem]') { - Assert::fail("{$path}: a cell grid sits inside a {$class} column, so its right edge draws a line through the page"); - } - } - } - } - - expect(true)->toBeTrue(); -})->with(frameTemplates()); - it('draws cells with one shared line, reaching the rails from 1280px and the screen edge below', function (): void { $css = frameCss(); @@ -152,24 +197,6 @@ function frameCss(): string ->toMatch('/@media \(min-width: 80rem\) \{\s*:root \{\s*--cell-bleed: calc\(2rem - 1px\);/'); }); -it('gives the homepage its cells and leaves its prose sections unlined', function (string $path): void { - $xpath = frameXPath(ssrHtml($path)); - $cells = fn(string $id): int => $xpath->query("//section[@id=\"{$id}\"]//*[contains(concat(' ', normalize-space(@class), ' '), ' cell-grid ')]")->length; - - foreach (['top', 'databases', 'sponsors', 'features', 'safety', 'platforms', 'pricing', 'open-source'] as $id) { - expect($cells($id))->toBe(1, "{$path}: #{$id} is a set of like items, drawn as cells"); - } - - foreach (['ai', 'switch'] as $id) { - expect($cells($id))->toBe(0, "{$path}: #{$id} is prose and stays unlined"); - } - - // A section that ends in cells closes them on the next join, not 48px above it. - foreach (['sponsors', 'features', 'safety', 'platforms', 'open-source'] as $id) { - expect(frameClasses($xpath->query("//section[@id=\"{$id}\"]")->item(0)))->toContain('pb-0', 'md:pb-0', 'xl:pb-0'); - } -})->with(['/', '/vi']); - it('lets the join close a list that ends a block, but never one inside a card', function (): void { expect(frameCss())->toContain('main > * > :is(dl, [data-rule-list]):not(.rounded-panel *):last-child') ->toContain('main > * > :last-child > :last-child > :last-child > :last-child > :last-child > :is(dl, [data-rule-list]):not(.rounded-panel *):last-child'); @@ -180,49 +207,12 @@ function frameCss(): string }); it('keeps the frame from clipping or widening the page', function (string $file): void { - /* - * The previous frame drew 200vw rules and hid the overflow on an ancestor, - * which masked horizontal-scroll regressions and broke the sticky header. - * Comments may still say so; the code may not. - */ + // The previous frame's 200vw rules and hidden overflow masked scroll regressions and broke the sticky header. $code = (string) preg_replace('#/\*.*?\*/#s', '', (string) file_get_contents(resource_path($file))); expect($code)->not->toMatch('/\d+vw\b|w-screen|overflow-x:\s*(?:hidden|clip)|overflow-x-(?:hidden|clip)/'); })->with(['css/app.css', 'css/frame.css', 'js/components/shared/frame-rails.tsx', 'js/components/ui/cell-grid.tsx', 'js/layouts/landing-layout.tsx']); -it('ends every inner rule on the frame: rail to rail, wall to wall, or inside a card', function (string $path): void { - /* - * The owner's complaint after the first frame: list and table rules that - * stopped 32px short of the rails, or at the 704px reading measure, read - * as loose lines in open space. Every ruled list and every table in - *
now reaches the rails (`frame-rows`, `frame-rows-text`, - * `frame-table`), or the walls of the cell it sits in (`cell-rows`), or - * lives inside a card whose edge closes it. A blog article's own tables - * are typography inside the reading column and stay as they are. - */ - $xpath = frameXPath(ssrHtml($path)); - $reaches = ['frame-rows', 'frame-rows-text', 'cell-rows', 'frame-table']; - $ruled = $xpath->query('//main//dl | //main//*[@data-rule-list] | //main//*[@role="region"][table]'); - - foreach ($ruled as $element) { - if (array_intersect($reaches, frameClasses($element)) !== []) { - continue; - } - - for ($node = $element->parentNode; $node instanceof DOMElement; $node = $node->parentNode) { - $classes = frameClasses($node); - - if (in_array('rounded-panel', $classes, true) || in_array('blog-article', $classes, true)) { - continue 2; - } - } - - Assert::fail("{$path}: a <{$element->nodeName}> with rules stops short of the frame; give it frame-rows, frame-rows-text, cell-rows or frame-table"); - } - - expect(true)->toBeTrue(); -})->with(frameTemplates()); - it('reaches the rails from any measure with container units, and keeps the text where it was', function (): void { $css = frameCss(); @@ -236,39 +226,3 @@ function frameCss(): string // Container units need a size container whose content box is the page's content column. expect((string) file_get_contents(resource_path('js/components/ui/section.tsx')))->toContain(''); }); - -it('separates the pricing and download topics with joins, not with rules inside one block', function (string $path, array $ids): void { - $xpath = frameXPath(ssrHtml($path)); - - foreach ($ids as $id) { - expect($xpath->query("//main/section[@id=\"{$id}\"]")->length)->toBe(1, "{$path}: #{$id} is a block of its own, so the frame's join separates it"); - } -})->with([ - 'pricing' => ['/pricing', ['license', 'billing', 'refunds', 'team', 'open-source', 'faq']], - 'download' => ['/download', ['install', 'updates', 'older-versions']], - 'post' => ['/blog/tablepro-0-77', ['related']], - 'blog' => ['/blog', ['guides', 'releases']], -]); - -it('sets each FAQ topic beside its questions, as two cells', function (): void { - $xpath = frameXPath(ssrHtml('/faq')); - $topics = $xpath->query('//main//section[@aria-labelledby]/*[contains(concat(" ", normalize-space(@class), " "), " cell-grid ")]'); - - expect($topics->length)->toBeGreaterThan(1); - - foreach ($topics as $grid) { - expect($xpath->query('./*', $grid)->length)->toBe(2, 'A topic is its heading cell and its questions cell'); - expect($xpath->query('.//*[@data-rule-list]', $grid)->item(0)?->getAttribute('class'))->toContain('cell-rows'); - } -}); - -it('runs each blog index list from rail to rail', function (): void { - $xpath = frameXPath(ssrHtml('/blog')); - - foreach (['guides', 'releases'] as $id) { - $list = $xpath->query("//main/section[@id=\"{$id}\"]//ol[@data-rule-list]")->item(0); - - expect($list)->not->toBeNull("#{$id} has no ruled list"); - expect(frameClasses($list))->toContain('frame-rows'); - } -}); diff --git a/tests/Feature/Chrome/SiteChromeTest.php b/tests/Feature/Chrome/SiteChromeTest.php index 5c104395..c938eb69 100644 --- a/tests/Feature/Chrome/SiteChromeTest.php +++ b/tests/Feature/Chrome/SiteChromeTest.php @@ -5,25 +5,7 @@ use Illuminate\Support\Facades\Storage; use PHPUnit\Framework\Assert; -/** - * The header, footer and frame every public page renders, in both languages - * (sitemap §B; positioning §10; design-system §5.3.17). - * - * The chrome is rendered by `LandingLayout` itself, so every page gets it, - * the branded error pages included. What matters is invisible to a typecheck: - * that each link keeps the reader's language, that the account link reaches - * the other application unprefixed, that a cross-language link forces a full - * page load, and that the landmarks a screen reader navigates by exist once. - */ - -/** - * Every anchor in a fragment, with its attributes and visible text. - * - * Attribute names are lowercased, as an HTML parser does: React writes the - * `hrefLang` prop as `hrefLang="en"`, which a browser reads as `hreflang`. - * - * @return list, text: string}> - */ +/** @return list, text: string}> */ function chromeAnchors(string $html): array { preg_match_all('#]*)>(.*?)#s', $html, $matches, PREG_SET_ORDER); @@ -34,6 +16,7 @@ function chromeAnchors(string $html): array $attrs = []; foreach ($pairs as $pair) { + // React writes hrefLang="en", which a browser reads as hreflang. $attrs[strtolower($pair[1])] = html_entity_decode($pair[2], ENT_QUOTES); } @@ -41,7 +24,7 @@ function chromeAnchors(string $html): array }, $matches); } -/** The first anchor whose href is `$href` and, when given, whose text contains `$text`. @return array{attrs: array, text: string}|null */ +/** @return array{attrs: array, text: string}|null */ function chromeLink(string $html, string $href, ?string $text = null): ?array { foreach (chromeAnchors($html) as $anchor) { @@ -53,14 +36,9 @@ function chromeLink(string $html, string $href, ?string $text = null): ?array return null; } -/** - * The first `<{$tag}>` element: the site header or footer, which come before - * and after `
`. Neither nests another of its own kind, so the first - * closing tag after it is its own; a page's `
` inside `
` is left - * out. - */ function chromeRegion(string $html, string $tag): string { + // The site header and footer nest none of their kind and come before any
inside
. $start = strpos($html, "<{$tag}"); $end = $start === false ? false : strpos($html, "", $start); @@ -75,9 +53,10 @@ function chromeRegion(string $html, string $tag): string 'Vietnamese' => ['/vi/download', 'vi', '/vi'], ]); -/** One catalog's strings by key, parsed from its TypeScript source: `key: '…'` in English and Vietnamese, `"key": "…"` elsewhere. @return array */ +/** @return array */ function chromeCatalog(string $locale, string $namespace): array { + // English and Vietnamese write key: '…', the other catalogs "key": "…". $source = (string) file_get_contents(dirname(__DIR__, 3) . "/resources/js/i18n/messages/{$locale}/{$namespace}.ts"); preg_match_all('/^\s+"?(\w+)"?: ([\'"])((?:(?!\2)[^\\\\]|\\\\.)*)\2,?$/m', $source, $matches, PREG_SET_ORDER); @@ -91,9 +70,10 @@ function chromeCatalog(string $locale, string $namespace): array return $strings; } -/** Every locale with its URL prefix and endonym, read from disk: a dataset is built before the application boots. @return array */ +/** @return array */ function chromeLocales(): array { + // From disk: a dataset is built before the application boots. $rows = []; foreach (json_decode((string) file_get_contents(dirname(__DIR__, 3) . '/resources/data/locales.json'), true)['supported'] as $code => $locale) { @@ -103,14 +83,7 @@ function chromeLocales(): array return $rows; } -/** - * The header's desktop row with every label on one line, measured in Chromium - * at 14px Inter: its width in px with the GitHub link at its widest (the word - * "GitHub", before any count is stored), and the display columns of its seven - * labels at the time (a CJK character is two). The language is a globe and the - * theme lives in the footer, so neither adds a label. The row has 960px at - * 1024 and 1088px at 1152. - */ +// [desktop row width in px, display columns of its seven labels], measured in Chromium; the row has 960px at 1024 and 1088px at 1152. const HEADER_ROW_MEASURED = [ 'en' => [880, 47], 'vi' => [911, 57], @@ -126,13 +99,20 @@ function chromeLocales(): array 'id' => [848, 42], ]; -it('frames the page with a skip link, one header, one main and one footer', function (string $path, string $locale): void { +it('renders the header, its menu and the footer in the reader\'s language', function (string $path, string $locale, string $prefix): void { + // No star count is stored, so the GitHub link is named by its word. + Storage::fake(StarCount::DISK); + $html = ssrHtml($path); + $header = chromeRegion($html, 'header'); + $footer = chromeRegion($html, 'footer'); + $vi = $locale === 'vi'; + $languageLabel = $vi ? 'Ngôn ngữ: Tiếng Việt' : 'Language: English'; $skip = chromeLink($html, '#main-content'); Assert::assertNotNull($skip, 'The skip link is missing'); - Assert::assertSame($locale === 'vi' ? 'Chuyển đến nội dung chính' : 'Skip to content', $skip['text']); + Assert::assertSame($vi ? 'Chuyển đến nội dung chính' : 'Skip to content', $skip['text']); Assert::assertLessThan(strpos($html, 'toBe(1); @@ -145,11 +125,6 @@ function chromeLocales(): array // Nothing bleeds past the viewport: the ledger's 200vw rules are gone. expect($html)->not->toContain('w-[200vw]'); -})->with('chrome locales'); - -it('links the header to the sections in the reader\'s language', function (string $path, string $locale, string $prefix): void { - $header = chromeRegion(ssrHtml($path), 'header'); - $vi = $locale === 'vi'; $expected = [ "{$prefix}/databases" => $vi ? 'Cơ sở dữ liệu' : 'Databases', @@ -163,7 +138,6 @@ function chromeLocales(): array Assert::assertNotNull(chromeLink($header, $href, $text), "The header has no \"{$text}\" link to {$href}"); } - // Features is a disclosure button over ordinary links, including the platform page from data. expect($header)->toMatch('/'); - // The copyright names the maker and the licence, with the year and facts.json's publisher filled in. - $publisher = json_decode((string) file_get_contents(resource_path('data/facts.json')), true)['publisher']; + $facts = json_decode((string) file_get_contents(resource_path('data/facts.json')), true); + + $publisher = $facts['publisher']; $line = $vi ? "/© \\d{4} TablePro, do {$publisher['name']} phát triển tại {$publisher['city']['vi']}\\. Mã nguồn theo giấy phép AGPLv3\\./u" : "/© \\d{4} TablePro, made by {$publisher['name']} in {$publisher['city']['en']}\\. Source code under the AGPLv3\\./"; expect($footer)->toMatch($line); -})->with('chrome locales'); -it('lists the community channels a reader of that language can use', function (string $path, string $locale): void { - $footer = chromeRegion(ssrHtml($path), 'footer'); - $links = json_decode((string) file_get_contents(resource_path('data/facts.json')), true)['links']; + $links = $facts['links']; Assert::assertNotNull(chromeLink($footer, $links['discussions'], 'GitHub Discussions'), 'The footer has no GitHub Discussions link'); Assert::assertNotNull(chromeLink($footer, $links['discord'], 'Discord')); Assert::assertNotNull(chromeLink($footer, $links['x'], 'X')); // The Telegram group is in Vietnamese, and the Facebook page is not kept up. - Assert::assertSame($locale === 'vi', chromeLink($footer, $links['telegram']) !== null, 'Telegram belongs on Vietnamese pages only'); + Assert::assertSame($vi, chromeLink($footer, $links['telegram']) !== null, 'Telegram belongs on Vietnamese pages only'); Assert::assertStringNotContainsString('facebook.com', $footer); expect($links)->not->toHaveKey('facebook'); @@ -439,30 +278,17 @@ function chromeLocales(): array Assert::assertNotNull($troubleshooting, 'The footer has no Troubleshooting link'); Assert::assertSame('en', $troubleshooting['attrs']['hreflang'] ?? null); Assert::assertLessThan(strpos($footer, 'href="mailto:'), strpos($footer, $links['troubleshooting'])); -})->with([ - 'English' => ['/download', 'en'], - 'Vietnamese' => ['/vi/download', 'vi'], - 'German' => ['/de/download', 'de'], -]); -it('offers the newsletter without a subscriber count', function (string $path, string $locale): void { - $footer = chromeRegion(ssrHtml($path), 'footer'); - - // One email field, required and named for the form; React decides the attribute order. + // React decides the attribute order. preg_match('/]*type="email"[^>]*>/', $footer, $email); Assert::assertNotEmpty($email, 'The footer has no email field'); expect($email[0])->toContain('name="email"')->toContain('required=""'); - expect($footer)->toContain('>' . ($locale === 'vi' ? 'Đăng ký nhận tin' : 'Subscribe') . '') + expect($footer)->toContain('>' . ($vi ? 'Đăng ký nhận tin' : 'Subscribe') . '') ->not->toMatch('/\d[\d,.]*\s+(developers|subscribers|lập trình viên|người đăng ký)/u'); -})->with('chrome locales'); - -it('ends the footer on a language menu that opens without JavaScript, one link per language', function (string $path, string $locale): void { - $footer = chromeRegion(ssrHtml($path), 'footer'); - // A native disclosure, closed on load and named with the current language. - expect($footer)->toMatch('/]*>]*aria-label="' . ($locale === 'vi' ? 'Ngôn ngữ: Tiếng Việt' : 'Language: English') . '"/u') + expect($footer)->toMatch('/]*>]*aria-label="' . $languageLabel . '"/u') ->not->toMatch('/]*\sopen[\s=>]/'); $panel = substr($footer, (int) strpos($footer, 'data-menu-panel')); @@ -477,8 +303,78 @@ function chromeLocales(): array } // The old inline list ("Language: English · Tiếng Việt · …") is gone. - expect($footer)->not->toContain($locale === 'vi' ? 'Ngôn ngữ:' : 'Language:'); -})->with('chrome locales'); + expect($footer)->not->toContain($vi ? 'Ngôn ngữ:' : 'Language:'); +})->with('chrome locales')->group('ssr'); + +it('links GitHub with its star count, in the header row and in the menu', function (string $path, string $locale): void { + Storage::fake(StarCount::DISK); + Storage::disk(StarCount::DISK)->put(StarCount::FILE, '6224'); + + $name = $locale === 'vi' ? 'GitHub, 6.2k star' : 'GitHub, 6.2k stars'; + $links = array_values(array_filter( + chromeAnchors(chromeRegion(ssrHtml($path), 'header')), + fn(array $anchor): bool => ($anchor['attrs']['href'] ?? null) === 'https://github.com/TableProApp/TablePro', + )); + + expect($links)->toHaveCount(2); + + [$bar, $menu] = $links; + + // The visible count is part of the name (WCAG 2.5.3), and the link opens in the same tab like every other. + expect($bar['text'])->toBe('6.2k') + ->and($bar['attrs']['aria-label'])->toBe($name) + ->and($bar['attrs']['class'])->toEndWith(' tabular-nums') + ->and($bar['attrs'])->not->toHaveKey('target') + ->and($menu['text'])->toBe('GitHub↗6.2k') + ->and($menu['attrs']['aria-label'])->toBe($name); +})->with('chrome locales')->group('ssr'); + +it('shows the desktop row only where it fits, and keeps the phone row on one line, in every language', function (string $code, string $prefix): void { + // At 1024px the row wrapped in Spanish and French and pushed the French Download 8px off the page. + [$width, $columns] = HEADER_ROW_MEASURED[$code]; + $wide = $width > 960; + + expect($width)->toBeLessThanOrEqual($wide ? 1088 : 960); + + $nav = chromeCatalog($code, 'nav'); + $labels = [$nav['features'], $nav['databases'], $nav['pricing'], $nav['docs'], $nav['blog'], $nav['account'], $nav['download']]; + + Assert::assertLessThanOrEqual($columns, array_sum(array_map(mb_strwidth(...), $labels)), "{$code}: a header label grew. Measure the row again and update HEADER_ROW_MEASURED."); + + // At 320px Japanese ダウンロード broke onto two lines; the phone row leaves Download 116px, which fits 12 columns. + Assert::assertLessThanOrEqual(12, mb_strwidth($nav['download']), "{$code}: the header Download label grew. Measure the phone header again."); + + $from = $wide ? 'min-[72rem]' : 'lg'; + $header = chromeRegion(ssrHtml("{$prefix}/download"), 'header'); + + expect($header)->toMatch('/