From abdbca6a06d0df1fa8f6b7dfe317a74fc34bd65b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 17:17:21 +0700 Subject: [PATCH 1/2] test: simplify the suite to the behavior it protects --- .../skills/announce/references/blog-post.md | 15 +- .claude/skills/announce/scripts/lint-draft.py | 2 +- .github/workflows/tests.yml | 6 +- CLAUDE.md | 10 +- .../Commands/GenerateSitemapCommand.php | 2 +- app/Support/Content/Slugs/DatabaseSlugs.php | 4 +- app/Support/Content/Slugs/FeatureSlugs.php | 3 +- app/Support/Features/FeatureFacts.php | 2 +- config/banner.php | 3 +- docs/architecture.md | 2 +- docs/deployment.md | 4 +- docs/shared-files.md | 5 +- lang/vi/errors.php | 2 +- resources/js/components/compare/README.md | 10 +- resources/js/components/compare/model.ts | 2 +- resources/js/components/databases/README.md | 8 +- resources/js/components/databases/types.ts | 3 +- .../download/app-store-badge-view.ts | 6 +- resources/js/components/download/types.ts | 2 +- resources/js/components/features/README.md | 8 +- resources/js/components/features/types.ts | 5 +- .../components/home/open-source-section.tsx | 2 +- resources/js/components/home/types.ts | 2 +- resources/js/components/pricing/types.ts | 2 +- .../js/components/site/support-banner.tsx | 3 +- .../js/components/sponsors/sponsor-grid.tsx | 1 - resources/js/i18n/messages/en/banner.ts | 3 +- resources/js/i18n/messages/en/controls.ts | 2 +- resources/js/i18n/paths.ts | 2 +- resources/js/lib/data/brand.ts | 2 +- resources/js/lib/data/paid-features.ts | 3 +- resources/js/lib/data/sponsors.ts | 2 +- resources/views/app.blade.php | 2 +- routes/localized.php | 2 +- tests/Feature/Assets/AssetManifestTest.php | 236 +----- tests/Feature/Assets/EmptySlotsTest.php | 15 - tests/Feature/Assets/ImageAssetsTest.php | 30 + .../LcpPreloadTest.php} | 28 - tests/Feature/Chrome/IconsTest.php | 56 -- tests/Feature/Chrome/LayoutRegressionTest.php | 210 ----- tests/Feature/Chrome/PageChunkTest.php | 120 --- tests/Feature/Chrome/PageFrameTest.php | 228 ----- tests/Feature/Chrome/SiteChromeTest.php | 459 ---------- tests/Feature/Compare/CompareContentTest.php | 317 ------- tests/Feature/Compare/ComparePagesTest.php | 268 ------ .../Console/ComparisonsCheckCommandTest.php | 47 +- .../Feature/Console/GenerateOgImagesTest.php | 26 - tests/Feature/Console/GenerateSitemapTest.php | 170 ---- .../Console/ReleaseCheckCommandTest.php | 83 +- tests/Feature/Content/BannedClaimsTest.php | 423 ---------- tests/Feature/Content/CopyStyleTest.php | 130 --- tests/Feature/Content/NoTypedCountsTest.php | 118 --- .../Feature/Content/PositioningGuardsTest.php | 364 -------- tests/Feature/Content/ReviewedFactsTest.php | 484 ----------- tests/Feature/Content/helpers.php | 430 ---------- .../Feature/Contracts/PublicContractsTest.php | 209 ----- tests/Feature/Data/BrandDataTest.php | 112 --- tests/Feature/Data/ComparisonsDataTest.php | 385 +-------- tests/Feature/Data/EnginesDataTest.php | 405 ++------- tests/Feature/Data/FactsDataTest.php | 357 ++------ tests/Feature/Data/IntegrationsDataTest.php | 61 +- tests/Feature/Data/LocalesDataTest.php | 60 -- tests/Feature/Data/PaidFeaturesDataTest.php | 162 ---- tests/Feature/Data/PlatformsDataTest.php | 172 +--- tests/Feature/Data/PricingDataTest.php | 201 ++--- tests/Feature/Data/RedirectsDataTest.php | 279 ------- tests/Feature/Data/SiteDataTest.php | 200 +++++ tests/Feature/Data/SponsorsDataTest.php | 182 ---- tests/Feature/Data/TestimonialsDataTest.php | 117 --- tests/Feature/Databases/DatabasePagesTest.php | 766 ----------------- tests/Feature/Databases/EnginePathsTest.php | 50 -- tests/Feature/Features/FeatureContentTest.php | 312 ------- tests/Feature/Features/FeaturePagesTest.php | 244 ------ .../Integrations/IntegrationPagesTest.php | 165 ---- tests/Feature/Landing/AboutPageTest.php | 196 ----- .../Feature/Landing/AnalyticsConsentTest.php | 274 ------ tests/Feature/Landing/BlogFeedTest.php | 122 --- tests/Feature/Landing/BlogTest.php | 737 ----------------- tests/Feature/Landing/BrandPageTest.php | 109 --- tests/Feature/Landing/CampaignPageTest.php | 126 --- tests/Feature/Landing/ColorContrastTest.php | 270 ------ tests/Feature/Landing/FaqPageTest.php | 190 ----- tests/Feature/Landing/HomepageRenderTest.php | 781 ------------------ tests/Feature/Landing/ImageAssetsTest.php | 150 ---- tests/Feature/Landing/InternalLinksTest.php | 329 -------- tests/Feature/Landing/IosPageTest.php | 298 ------- tests/Feature/Landing/LandingSeoTest.php | 228 ----- tests/Feature/Landing/LandingTest.php | 109 --- tests/Feature/Landing/LanguageBarTest.php | 119 --- tests/Feature/Landing/LegalPagesTest.php | 646 --------------- tests/Feature/Landing/LicenseFeaturesTest.php | 83 -- .../Landing/PurchaseAttributionTest.php | 149 ---- tests/Feature/Landing/SecurityPageTest.php | 126 --- tests/Feature/Landing/SponsorsPageTest.php | 132 --- tests/Feature/Landing/TeamsPageTest.php | 73 -- tests/Feature/Landing/TopBannerTest.php | 227 ----- tests/Feature/Localization/AppLabelsTest.php | 259 ------ .../Localization/ContentParityTest.php | 612 -------------- ...dingTest.php => ContentRepositoryTest.php} | 27 +- tests/Feature/Localization/ErrorPageTest.php | 216 ----- .../Feature/Localization/ForeignTextTest.php | 45 - .../Localization/HomeSentencesTest.php | 96 --- tests/Feature/Localization/LangParityTest.php | 67 -- .../Localization/LocaleRoutingTest.php | 441 +++++----- .../Feature/Localization/LocalizedUrlTest.php | 132 --- .../Feature/Localization/PageRegistryTest.php | 9 +- .../Feature/Localization/TranslationsTest.php | 221 +++++ tests/Feature/Localization/TypographyTest.php | 124 --- tests/Feature/Localization/WordingTest.php | 319 ------- tests/Feature/Pages/BlogTest.php | 252 ++++++ tests/Feature/Pages/CampaignTest.php | 180 ++++ tests/Feature/Pages/CompareTest.php | 147 ++++ tests/Feature/Pages/DatabasesTest.php | 157 ++++ tests/Feature/Pages/DownloadTest.php | 122 +++ tests/Feature/Pages/ErrorPagesTest.php | 156 ++++ tests/Feature/Pages/FeaturesTest.php | 157 ++++ tests/Feature/Pages/IntegrationsTest.php | 91 ++ tests/Feature/Pages/InternalLinksTest.php | 222 +++++ tests/Feature/Pages/PagesTest.php | 158 ++++ tests/Feature/Pages/StaticPagesTest.php | 161 ++++ tests/Feature/Pricing/CampaignTest.php | 141 ---- tests/Feature/Pricing/PricingPageTest.php | 368 --------- tests/Feature/Pricing/RegionalPricingTest.php | 56 -- tests/Feature/Releases/DownloadPageTest.php | 332 -------- .../{ => Repo}/AnnounceLintDraftTest.php | 5 +- tests/Feature/{ => Repo}/DeployScriptTest.php | 192 +---- tests/Feature/Repo/SharedFilesTest.php | 49 ++ tests/Feature/{ => Repo}/WorkflowsTest.php | 1 - .../Security/ContentSecurityPolicyTest.php | 16 +- tests/Feature/Security/HeadersTest.php | 95 +++ tests/Feature/Security/PrivacyTest.php | 226 +++++ .../Feature/Security/SecurityHeadersTest.php | 42 - tests/Feature/Security/SecurityTxtTest.php | 18 +- tests/Feature/Seo/BespokeOgCardTest.php | 144 ---- tests/Feature/Seo/EdgeCacheTest.php | 18 +- tests/Feature/Seo/ForwardedHostTest.php | 26 - tests/Feature/Seo/HeadTest.php | 249 ++++++ tests/Feature/Seo/HreflangReciprocityTest.php | 215 ----- tests/Feature/Seo/LlmsTxtTest.php | 69 +- tests/Feature/Seo/MarkdownVersionsTest.php | 104 +-- tests/Feature/Seo/OgCardsTest.php | 45 +- tests/Feature/Seo/PageFamiliesTest.php | 62 +- tests/Feature/Seo/RedirectsTest.php | 427 +++++----- tests/Feature/Seo/SeoLengthsTest.php | 180 ---- tests/Feature/Seo/SeoSmokeTest.php | 11 - tests/Feature/Seo/SitemapAlternatesTest.php | 81 -- tests/Feature/Seo/SitemapTest.php | 146 ++++ .../MacReleaseServiceTest.php | 6 - .../{GitHub => Services}/StarCountTest.php | 7 +- tests/Feature/SharedFilesTest.php | 120 --- tests/Feature/SsrServerTest.php | 34 - tests/Feature/SuiteHygieneTest.php | 104 --- tests/Feature/Theme/ThemeControlTest.php | 122 --- tests/Feature/Theme/ThemeHeadTest.php | 114 --- tests/Feature/ThirdParty/ScriptsTest.php | 108 --- tests/Pest.php | 9 + tests/Support/app-ui-labels.php | 122 --- tests/Support/locale-forbidden-variants.php | 417 ---------- .../releases.php} | 22 - .../Seo/helpers.php => Support/seo.php} | 31 +- tests/Support/ssr.php | 8 - tests/Support/vi-forbidden-variants.php | 68 -- tests/js/analytics.test.ts | 6 +- tests/js/anchor-glide.test.ts | 20 - tests/js/app-store-badge.test.ts | 104 --- tests/js/asset-slot.test.ts | 107 +-- tests/js/attribution.test.ts | 30 +- tests/js/blog-article.test.ts | 29 - tests/js/campaign-banner.test.ts | 48 -- tests/js/campaign.test.ts | 20 +- tests/js/checkout.test.ts | 27 +- tests/js/compare-model.test.ts | 280 +++---- tests/js/consent.test.ts | 17 +- tests/js/content-nfc.test.ts | 90 -- tests/js/crisp.test.ts | 16 +- tests/js/device.test.ts | 12 +- tests/js/feature-model.test.ts | 33 +- tests/js/i18n-catalogs.test.ts | 74 +- tests/js/i18n-core.test.ts | 8 +- tests/js/keep-together.test.ts | 5 - tests/js/language-suggestion.test.ts | 12 +- tests/js/locale-conventions.test.ts | 113 --- tests/js/paths.test.ts | 5 - tests/js/prose-html.test.ts | 27 +- tests/js/public-request-credentials.test.ts | 28 - tests/js/regional-pricing.test.ts | 35 +- tests/js/structured-data.test.ts | 18 +- tests/js/theme.test.ts | 15 +- 188 files changed, 4129 insertions(+), 20318 deletions(-) create mode 100644 tests/Feature/Assets/ImageAssetsTest.php rename tests/Feature/{Localization/RootTemplateTest.php => Assets/LcpPreloadTest.php} (72%) delete mode 100644 tests/Feature/Chrome/IconsTest.php delete mode 100644 tests/Feature/Chrome/LayoutRegressionTest.php delete mode 100644 tests/Feature/Chrome/PageChunkTest.php delete mode 100644 tests/Feature/Chrome/PageFrameTest.php delete mode 100644 tests/Feature/Chrome/SiteChromeTest.php delete mode 100644 tests/Feature/Compare/CompareContentTest.php delete mode 100644 tests/Feature/Compare/ComparePagesTest.php delete mode 100644 tests/Feature/Console/GenerateSitemapTest.php delete mode 100644 tests/Feature/Content/BannedClaimsTest.php delete mode 100644 tests/Feature/Content/CopyStyleTest.php delete mode 100644 tests/Feature/Content/NoTypedCountsTest.php delete mode 100644 tests/Feature/Content/PositioningGuardsTest.php delete mode 100644 tests/Feature/Content/ReviewedFactsTest.php delete mode 100644 tests/Feature/Content/helpers.php delete mode 100644 tests/Feature/Contracts/PublicContractsTest.php delete mode 100644 tests/Feature/Data/BrandDataTest.php delete mode 100644 tests/Feature/Data/LocalesDataTest.php delete mode 100644 tests/Feature/Data/PaidFeaturesDataTest.php delete mode 100644 tests/Feature/Data/RedirectsDataTest.php create mode 100644 tests/Feature/Data/SiteDataTest.php delete mode 100644 tests/Feature/Data/SponsorsDataTest.php delete mode 100644 tests/Feature/Data/TestimonialsDataTest.php delete mode 100644 tests/Feature/Databases/DatabasePagesTest.php delete mode 100644 tests/Feature/Databases/EnginePathsTest.php delete mode 100644 tests/Feature/Features/FeatureContentTest.php delete mode 100644 tests/Feature/Features/FeaturePagesTest.php delete mode 100644 tests/Feature/Integrations/IntegrationPagesTest.php delete mode 100644 tests/Feature/Landing/AboutPageTest.php delete mode 100644 tests/Feature/Landing/AnalyticsConsentTest.php delete mode 100644 tests/Feature/Landing/BlogFeedTest.php delete mode 100644 tests/Feature/Landing/BlogTest.php delete mode 100644 tests/Feature/Landing/BrandPageTest.php delete mode 100644 tests/Feature/Landing/CampaignPageTest.php delete mode 100644 tests/Feature/Landing/ColorContrastTest.php delete mode 100644 tests/Feature/Landing/FaqPageTest.php delete mode 100644 tests/Feature/Landing/HomepageRenderTest.php delete mode 100644 tests/Feature/Landing/ImageAssetsTest.php delete mode 100644 tests/Feature/Landing/InternalLinksTest.php delete mode 100644 tests/Feature/Landing/IosPageTest.php delete mode 100644 tests/Feature/Landing/LandingSeoTest.php delete mode 100644 tests/Feature/Landing/LandingTest.php delete mode 100644 tests/Feature/Landing/LanguageBarTest.php delete mode 100644 tests/Feature/Landing/LegalPagesTest.php delete mode 100644 tests/Feature/Landing/LicenseFeaturesTest.php delete mode 100644 tests/Feature/Landing/PurchaseAttributionTest.php delete mode 100644 tests/Feature/Landing/SecurityPageTest.php delete mode 100644 tests/Feature/Landing/SponsorsPageTest.php delete mode 100644 tests/Feature/Landing/TeamsPageTest.php delete mode 100644 tests/Feature/Landing/TopBannerTest.php delete mode 100644 tests/Feature/Localization/AppLabelsTest.php delete mode 100644 tests/Feature/Localization/ContentParityTest.php rename tests/Feature/Localization/{ContentLoadingTest.php => ContentRepositoryTest.php} (89%) delete mode 100644 tests/Feature/Localization/ErrorPageTest.php delete mode 100644 tests/Feature/Localization/ForeignTextTest.php delete mode 100644 tests/Feature/Localization/HomeSentencesTest.php delete mode 100644 tests/Feature/Localization/LangParityTest.php delete mode 100644 tests/Feature/Localization/LocalizedUrlTest.php create mode 100644 tests/Feature/Localization/TranslationsTest.php delete mode 100644 tests/Feature/Localization/TypographyTest.php delete mode 100644 tests/Feature/Localization/WordingTest.php create mode 100644 tests/Feature/Pages/BlogTest.php create mode 100644 tests/Feature/Pages/CampaignTest.php create mode 100644 tests/Feature/Pages/CompareTest.php create mode 100644 tests/Feature/Pages/DatabasesTest.php create mode 100644 tests/Feature/Pages/DownloadTest.php create mode 100644 tests/Feature/Pages/ErrorPagesTest.php create mode 100644 tests/Feature/Pages/FeaturesTest.php create mode 100644 tests/Feature/Pages/IntegrationsTest.php create mode 100644 tests/Feature/Pages/InternalLinksTest.php create mode 100644 tests/Feature/Pages/PagesTest.php create mode 100644 tests/Feature/Pages/StaticPagesTest.php delete mode 100644 tests/Feature/Pricing/CampaignTest.php delete mode 100644 tests/Feature/Pricing/PricingPageTest.php delete mode 100644 tests/Feature/Pricing/RegionalPricingTest.php delete mode 100644 tests/Feature/Releases/DownloadPageTest.php rename tests/Feature/{ => Repo}/AnnounceLintDraftTest.php (97%) rename tests/Feature/{ => Repo}/DeployScriptTest.php (76%) create mode 100644 tests/Feature/Repo/SharedFilesTest.php rename tests/Feature/{ => Repo}/WorkflowsTest.php (97%) create mode 100644 tests/Feature/Security/HeadersTest.php create mode 100644 tests/Feature/Security/PrivacyTest.php delete mode 100644 tests/Feature/Security/SecurityHeadersTest.php delete mode 100644 tests/Feature/Seo/BespokeOgCardTest.php delete mode 100644 tests/Feature/Seo/ForwardedHostTest.php create mode 100644 tests/Feature/Seo/HeadTest.php delete mode 100644 tests/Feature/Seo/HreflangReciprocityTest.php delete mode 100644 tests/Feature/Seo/SeoLengthsTest.php delete mode 100644 tests/Feature/Seo/SeoSmokeTest.php delete mode 100644 tests/Feature/Seo/SitemapAlternatesTest.php create mode 100644 tests/Feature/Seo/SitemapTest.php rename tests/Feature/{Releases => Services}/MacReleaseServiceTest.php (98%) rename tests/Feature/{GitHub => Services}/StarCountTest.php (91%) delete mode 100644 tests/Feature/SharedFilesTest.php delete mode 100644 tests/Feature/SsrServerTest.php delete mode 100644 tests/Feature/SuiteHygieneTest.php delete mode 100644 tests/Feature/Theme/ThemeControlTest.php delete mode 100644 tests/Feature/Theme/ThemeHeadTest.php delete mode 100644 tests/Feature/ThirdParty/ScriptsTest.php delete mode 100644 tests/Support/app-ui-labels.php delete mode 100644 tests/Support/locale-forbidden-variants.php rename tests/{Feature/Releases/ReleaseFixtures.php => Support/releases.php} (91%) rename tests/{Feature/Seo/helpers.php => Support/seo.php} (88%) delete mode 100644 tests/Support/vi-forbidden-variants.php delete mode 100644 tests/js/app-store-badge.test.ts delete mode 100644 tests/js/campaign-banner.test.ts delete mode 100644 tests/js/content-nfc.test.ts delete mode 100644 tests/js/locale-conventions.test.ts delete mode 100644 tests/js/public-request-credentials.test.ts diff --git a/.claude/skills/announce/references/blog-post.md b/.claude/skills/announce/references/blog-post.md index 445e508f..0d8f85ea 100644 --- a/.claude/skills/announce/references/blog-post.md +++ b/.claude/skills/announce/references/blog-post.md @@ -49,7 +49,7 @@ Headings are plain statements or names. No questions, slogans or gerund stacks. --- slug: tablepro-0-81 # equals the filename title: "TablePro 0.81: Tabs per Database, Firebase and Bigtable" # quote a title with a colon -description: One or two sentences, at most 155 characters (SeoLengthsTest). +description: One or two sentences, at most 155 characters. date: 2026-10-16 # the publish date, never changed later release: "TablePro 0.81" # marks a release post; a guide has none author: TablePro Team @@ -61,7 +61,7 @@ ogPunchline: Two or three short sentences under about 90 characters. The OG card - Every tag after `release` is a key of `PostTopics::PAGES` or an engine id in `resources/data/engines.json`; `BlogTest` fails on any other. - `ogPunchline` is what someone reads on a shared link, not the description restated. It counts no - engines (`NoTypedCountsTest`), and neither field makes a privacy claim (`BannedClaimsTest`). + engines, and neither field makes a privacy claim. ## 5. Figures @@ -96,19 +96,14 @@ Dev to dev, plain and short: about 900 words at most, three or four figures. ## 7. Wire it up -1. `tests/Feature/Landing/BlogTest.php`: - - the slug and date at the top of `BLOG_PUBLISHED`; - - in the `/blog` test, the newest post's `dateFormatted`; - - in the `/vi/blog` test, `posts.0` (slug, url, title, Vietnamese date) and the `slice(N, $guides)` - offset, which counts the release posts newer than the newest guide. -2. Render the OG card and look at it: a long title wraps to three lines and can push the punchline +1. Render the OG card and look at it: a long title wraps to three lines and can push the punchline off. `WEB_DOMAIN=tablepro.app php artisan og:generate --type=blog --slug=`. -3. Run: +2. Run: ```bash python3 .claude/skills/announce/scripts/lint-draft.py --blog resources/blog/.md ../TablePro/scripts/check-banned-words.sh resources/blog/.md - php artisan test --compact --filter='BlogTest|SeoLengthsTest|OgCardsTest|AssetManifestTest' + php artisan test --compact --filter='BlogTest|OgCardsTest|AssetManifestTest' php artisan test --compact ``` diff --git a/.claude/skills/announce/scripts/lint-draft.py b/.claude/skills/announce/scripts/lint-draft.py index dd8f40b3..14486b02 100755 --- a/.claude/skills/announce/scripts/lint-draft.py +++ b/.claude/skills/announce/scripts/lint-draft.py @@ -183,7 +183,7 @@ def check_blog(text, draft_path, add): add(HARD, "frontmatter", "a title with a colon must be quoted") description = unquote(fields.get("description", "")) if len(description) > DESCRIPTION_MAX: - add(HARD, "description", f"{len(description)} chars, SeoLengthsTest allows {DESCRIPTION_MAX}") + add(HARD, "description", f"{len(description)} chars, the limit allows {DESCRIPTION_MAX}") punchline = unquote(fields.get("ogPunchline", "")) if len(punchline) > OG_PUNCHLINE_MAX: add(SOFT, "ogPunchline", f"{len(punchline)} chars, keep it under about {OG_PUNCHLINE_MAX}") diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c8244264..57306d30 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -84,11 +84,7 @@ jobs: - name: Typecheck run: npm run typecheck - # Node's own runner over `tests/js`, no test dependency to install. The - # attribution module decides which source a sale is credited to and is - # the only frontend logic here that cannot be checked by rendering a - # page, so it is covered by execution rather than by the source-reading - # assertions the Pest suite uses. + # Node's own runner over `tests/js`, no test dependency to install. - name: Frontend tests run: npm run test:js diff --git a/CLAUDE.md b/CLAUDE.md index a8e007d2..79ae3f34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,15 +61,11 @@ server (`docs/architecture.md`, "The language bar"). browser agree. - Vietnamese text is NFC and follows the glossary (`docs/rebuild/design/positioning.md` §11): the reader is "bạn", developer - terms stay in English. `tests/Support/vi-forbidden-variants.php` lists the - wording that is ruled out. + terms stay in English. - In every language, the names of paid features, plans, modes, Safe Mode levels and app commands stay in English ("Safe Mode", "Data Rewind", "Team"). - `tests/Support/locale-forbidden-variants.php` lists the wording ruled out per - language. -- A label inside `` is what the app shows in that language: - `tests/Support/app-ui-labels.php` holds the app's wording where it is - translated, followed in the copy by the English label in parentheses. Every +- A label inside `` is what the app shows in that language. Where the app + translates it, the copy follows it with the English label in parentheses. Every other language keeps the English label as written. - French takes a no-break space before `; : ! ?` and inside `« »`. Japanese and Chinese put a space between Latin and CJK text, list joiners included. diff --git a/app/Console/Commands/GenerateSitemapCommand.php b/app/Console/Commands/GenerateSitemapCommand.php index 4ace9ef1..161c3fbe 100644 --- a/app/Console/Commands/GenerateSitemapCommand.php +++ b/app/Console/Commands/GenerateSitemapCommand.php @@ -20,7 +20,7 @@ * * - A translated page lists every translation as an `xhtml:link` alternate, * itself included, plus `x-default` pointing at the English URL. These are - * exactly the head's alternates (`SitemapAlternatesTest`). + * exactly the head's alternates (`Seo/SitemapTest`). * - An untranslated page (an English-only release post, `/blog`) has none. * - A page that renders but is not indexed (`/vi/blog`) is absent, as are * redirect and 410 sources (the registry never lists them), error pages and diff --git a/app/Support/Content/Slugs/DatabaseSlugs.php b/app/Support/Content/Slugs/DatabaseSlugs.php index 814aae6a..95f98040 100644 --- a/app/Support/Content/Slugs/DatabaseSlugs.php +++ b/app/Support/Content/Slugs/DatabaseSlugs.php @@ -14,8 +14,8 @@ * `resources/data/engines.json`, in that file's order. The four pages merged * into family pages (`mariadb-client`, `cockroachdb-client`, `pglite-client`, * `scylladb-client`) are not here: `resources/data/redirects.json` answers them - * with a 301 to their section before routing. `Data/EnginesDataTest` and - * `Databases/DatabasePagesTest` pin this list against `engines.json` and + * with a 301 to their section before routing. `Pages/DatabasesTest` and + * `Localization/LocaleRoutingTest` pin this list against `engines.json` and * `resources/data/content/{en,vi}/databases/*.json`. * * Never add `ios`, a locale prefix such as `vi`, or another root path such as diff --git a/app/Support/Content/Slugs/FeatureSlugs.php b/app/Support/Content/Slugs/FeatureSlugs.php index 0fd8f97c..cdb7517f 100644 --- a/app/Support/Content/Slugs/FeatureSlugs.php +++ b/app/Support/Content/Slugs/FeatureSlugs.php @@ -9,8 +9,7 @@ * A slug renders only in the locales where * `resources/data/content/{locale}/features/{slug}.json` exists; the registry * decides that, not the route. `Localization/LocaleRoutingTest` pins this - * list to the content files, and `Features/FeatureContentTest` checks each - * file against the schema in `resources/js/components/features/README.md`. + * list to the content files. */ final class FeatureSlugs { diff --git a/app/Support/Features/FeatureFacts.php b/app/Support/Features/FeatureFacts.php index 000fd318..ed1c113e 100644 --- a/app/Support/Features/FeatureFacts.php +++ b/app/Support/Features/FeatureFacts.php @@ -6,7 +6,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\File; -// What each {token} and engines list in feature copy means, so the copy stays true when the data changes; FeatureContentTest fails on a name it does not know. +// What each {token} and engines list in feature copy means, so the copy stays true when the data changes; Pages/FeaturesTest fails on a name it does not know. /** * @phpstan-type EngineItem array{name: string, href: string|null, detail: string|null, since: string|null} * @phpstan-type Fact array{kind: 'number', value: int}|array{kind: 'text', value: string}|array{kind: 'names', items: list}|array{kind: 'engines', items: list} diff --git a/config/banner.php b/config/banner.php index 081aed50..e5a3d29d 100644 --- a/config/banner.php +++ b/config/banner.php @@ -15,8 +15,7 @@ | Config holds the switch and the link, and nothing else. The words live in | the `banner` UI catalog, once per language | (resources/js/i18n/messages/{locale}/banner.ts), so the banner reads in - | the page's language and its length limits are checked in each - | (TopBannerTest). + | the page's language. | | It states a fact and never pleads: no "we need your help", no countdown, | no orange fill, only a tint of the brand hue (app.css). It cannot be diff --git a/docs/architecture.md b/docs/architecture.md index bbd5b95e..e1393535 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -225,7 +225,7 @@ in production, shared with every page as the `apiOrigin` prop and read through Every call is a plain `fetch` with `credentials: 'omit'`, so no cookie is sent or kept. tablepro-cloud answers CORS for `https://tablepro.app` only, without credentials. `ContentSecurityPolicy` adds the API origin to `connect-src`. -`PublicContractsTest` holds every call. +`Security/PrivacyTest` holds every call. Errors are RFC 9457 problems whose `title` and `detail` are English, so the cards show their own catalog's words for each `code` diff --git a/docs/deployment.md b/docs/deployment.md index 3ad2e7b0..d966516f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -29,7 +29,7 @@ the host's. On this origin: nginx matches those paths at the root only, so `/vi/account` comes here and answers 404 with a link to the portal. `PLATFORM_PATHS` keeps the site from -prefixing them, and `LocalizedUrlTest` holds it to the file. Ninety days after +prefixing them, and `LocaleRoutingTest` holds it to the file. Ninety days after the cutover, remove the two proxied locations from the host and from the file; the redirects stay. @@ -373,7 +373,7 @@ www-data ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.5-fpm, /usr/bin/sup ``` sudo matches each command line literally — path, verb and unit — and -`scripts/deploy.sh` runs exactly the first two (`tests/Feature/DeployScriptTest.php` +`scripts/deploy.sh` runs exactly the first two (`tests/Feature/Repo/DeployScriptTest.php` checks its calls against the rule above). The third restarts the platform app's SSR program (see "The SSR process"). A PHP upgrade renames the FPM unit, and this rule has to follow it; a deploy prints the unit it reloads. diff --git a/docs/shared-files.md b/docs/shared-files.md index 0d00947d..73ef1aef 100644 --- a/docs/shared-files.md +++ b/docs/shared-files.md @@ -34,7 +34,7 @@ Merge the tablepro-cloud side first only when its CI tolerates the difference repository first. A shared file imports nothing: each app reaches it through its own path, and -strings arrive as arguments. `SharedFilesTest` checks the imports. +strings arrive as arguments. ## Shared files @@ -53,9 +53,6 @@ both change together. No hash pins them. `web/packages/ui/src/styles/fonts.css`: the same faces, unicode ranges and declaration order (latin-ext, vietnamese, latin); the `url()` paths follow each app's bundler. -- `tests/Support/vi-forbidden-variants.php` and tablepro-cloud's - `web/packages/i18n/test/vi-forbidden-variants.ts`: the same rows, as PCRE - here and as JavaScript patterns there. ## Changing a header diff --git a/lang/vi/errors.php b/lang/vi/errors.php index 889fcd50..595be42b 100644 --- a/lang/vi/errors.php +++ b/lang/vi/errors.php @@ -6,7 +6,7 @@ |-------------------------------------------------------------------------- | | Nội dung cho resources/views/errors/{500,503}.blade.php. Giữ cùng khóa và -| cùng tham số với lang/en/errors.php (LangParityTest kiểm tra điều này). +| cùng tham số với lang/en/errors.php (TranslationsTest kiểm tra điều này). | */ diff --git a/resources/js/components/compare/README.md b/resources/js/components/compare/README.md index e889146c..81e1bd34 100644 --- a/resources/js/components/compare/README.md +++ b/resources/js/components/compare/README.md @@ -25,7 +25,7 @@ No benchmark, no rating, no `Review` or `FAQPage` markup, no competitor screensh ## The page file -Keys and their order are fixed (`CompareContentTest`). Both languages have the **same keys in the same order, the same list lengths and the same `{tokens}`** (`ContentParityTest`). +Keys and their order are fixed. Both languages have the **same keys in the same order, the same list lengths and the same `{tokens}`** (`TranslationsTest`). ```jsonc { @@ -63,7 +63,7 @@ Keys and their order are fixed (`CompareContentTest`). Both languages have the * ### Tokens -Prose may use only these, filled from the product's data (anything else prints literally and fails `CompareContentTest`): +Prose may use only these, filled from the product's data (anything else prints literally and fails `CompareTest`): | Token | Value | |---|---| @@ -79,7 +79,7 @@ Tags: `…` and `…` (an app label or menu path). Nothing ### Never in a compare file -- A price, a URL, a download size, a speed, memory or startup figure (`CompareContentTest` and positioning §12). Prices render from data in the table. +- A price, a URL, a download size, a speed, memory or startup figure (positioning §12). Prices render from data in the table. - A count of TablePro's engines, MCP tools, AI providers, Safe Mode levels or paid features. - "every/any/all database(s)", "every/all platforms", "cross-platform" about TablePro, "unlock", "faster than", "lighter", "free forever", "no feature gating", hype words. Vietnamese: "mọi cơ sở dữ liệu", "mọi nền tảng", "mở khóa", "nhanh hơn", "nhẹ hơn". "cross-platform", "macOS 14", "Mac App Store" and "Setapp" are allowed only about the other product. - "Free" alone for TablePro, adversarial words ("too slow", "bloated"), guesses. A fact nobody verified is left out. @@ -102,11 +102,11 @@ Every competitor fact on the page must be in the product's `comparisons.json` en ## Checks ```bash -php artisan test --compact --filter='ComparisonsDataTest|CompareContentTest|ComparePagesTest|ContentParityTest|LocaleRoutingTest' +php artisan test --compact --filter='ComparisonsDataTest|CompareTest|TranslationsTest|LocaleRoutingTest' npm run typecheck && npm run test:js ``` -`ComparisonsDataTest` → "gives every compared product a page in both languages" and `LocaleRoutingTest` → "pins each slug constant…" fail until every comparison exists in both languages. The server-rendered cases in `ComparePagesTest` run where SSR is available. +`ComparisonsDataTest` and `LocaleRoutingTest` fail until every comparison exists in every language. ## Per page (sitemap §A.4) diff --git a/resources/js/components/compare/model.ts b/resources/js/components/compare/model.ts index 47260c03..9c540ed2 100644 --- a/resources/js/components/compare/model.ts +++ b/resources/js/components/compare/model.ts @@ -199,7 +199,7 @@ function cellSources(cell: Pick): string[] { /** * The source ids behind a list item's `cite` entries: `platforms`, `licence`, * `status`, `mac`, `technology`, `prices` or `cells.{key}`. An entry that - * names nothing in the data is ignored here; `CompareContentTest` rejects it. + * names nothing in the data is ignored here; `CompareTest` rejects it. */ export function citedSources(product: ComparisonProduct, cite: readonly string[]): string[] { const ids: string[] = []; diff --git a/resources/js/components/databases/README.md b/resources/js/components/databases/README.md index 5261ce33..e7522af3 100644 --- a/resources/js/components/databases/README.md +++ b/resources/js/components/databases/README.md @@ -12,8 +12,8 @@ already holds. | `resources/data/content/{en,vi}/databases/index.json` | The hub's copy, plus `labels`: the shared words every engine page uses | | `resources/data/content/{en,vi}/databases/{slug}.json` | One engine page's copy | -`tests/Feature/Databases/DatabasePagesTest.php` validates every page file against the schema below, and -`Localization/ContentParityTest` holds `vi` to the same keys, `{tokens}` and asset ids as `en`. +`tests/Feature/Pages/DatabasesTest.php` checks the tokens, tags and links of every page file, and +`Localization/TranslationsTest` holds every other language to the same keys, `{tokens}` and asset ids as `en`. ## What a page contributor writes @@ -135,11 +135,11 @@ Tags do not nest. A number that belongs to the product (a cap, a count) belongs index, export…), "khóa chính" and "khóa ngoại", "dòng" and "cột", "chỉ đọc", tone marks on the first vowel (khóa, xóa, tùy, hủy). An app label appears in Vietnamese first with the English in parentheses, as the app shows it, for example "Tệp từ xa (Remote File)". Feature names stay English (Preview SQL, Users & Roles, Copy To, - Compare & Sync). Text is NFC. The forbidden variants are in `tests/Support/vi-forbidden-variants.php`. + Compare & Sync). Text is NFC. ## Checks ```bash -php artisan test --compact --filter='DatabasePagesTest|EnginesDataTest|ContentParityTest|AssetManifestTest' +php artisan test --compact --filter='DatabasesTest|EnginesDataTest|TranslationsTest|AssetManifestTest' npm run typecheck ``` diff --git a/resources/js/components/databases/types.ts b/resources/js/components/databases/types.ts index 0a257e0f..0f6021e3 100644 --- a/resources/js/components/databases/types.ts +++ b/resources/js/components/databases/types.ts @@ -1,8 +1,7 @@ /** * The props `App\Http\Controllers\DatabaseController` sends, and the * shape of the database pages' copy. README.md in this folder documents the - * content schema; `tests/Feature/Databases/DatabasePagesTest.php` validates - * every content file against it, so these types and the files cannot drift. + * content schema. */ import type { Requirements } from '@/lib/data/platforms'; diff --git a/resources/js/components/download/app-store-badge-view.ts b/resources/js/components/download/app-store-badge-view.ts index 048cb728..5d9655bb 100644 --- a/resources/js/components/download/app-store-badge-view.ts +++ b/resources/js/components/download/app-store-badge-view.ts @@ -2,9 +2,9 @@ * The markup of ``, as a plain function of the locale. * * Written with `createElement` rather than JSX so `node --test` can render it - * through `react-dom/server` with no build step: tests/js/app-store-badge.test.ts - * renders it in every locale. `app-store-badge.tsx` is the component that binds - * it to the page's locale, its catalog label and the download event. + * through `react-dom/server` with no build step. `app-store-badge.tsx` is the + * component that binds it to the page's locale, its catalog label and the + * download event. * * Imports are relative and type-only, so Node resolves them without the `@/` * alias. diff --git a/resources/js/components/download/types.ts b/resources/js/components/download/types.ts index 635ac853..0b45e468 100644 --- a/resources/js/components/download/types.ts +++ b/resources/js/components/download/types.ts @@ -1,6 +1,6 @@ /** * The props `App\Http\Controllers\DownloadController` sends, and the - * page copy's type. The copy's shape is the English file's: `ContentParityTest` + * page copy's type. The copy's shape is the English file's: `TranslationsTest` * holds every other locale to the same keys. */ import type { PostSummary } from '@/components/blog/post-list'; diff --git a/resources/js/components/features/README.md b/resources/js/components/features/README.md index 0c8973e1..73ed6963 100644 --- a/resources/js/components/features/README.md +++ b/resources/js/components/features/README.md @@ -2,7 +2,7 @@ `/features/{slug}` and `/vi/features/{slug}` are one template (`pages/Features/Show.tsx`) filled from `resources/data/content/{en,vi}/features/{slug}.json`. This file is the contract for whoever writes those files. -`tests/Feature/Features/FeatureContentTest.php` enforces it; run it after every edit. +`tests/Feature/Pages/FeaturesTest.php` checks its facts, links and tokens; run it after every edit. Sources of truth, in order: `docs/rebuild/design/sitemap.md` §A.2 (sections, ids, slots) and §E.4 (blueprint), `positioning.md` §11 (glossary) and §12 (banned claims), and the TablePro app repository at the `v0.77.0` tag for @@ -169,7 +169,7 @@ in a block's `engines`: `{ "label": "Plan as text", "list": "explainText" }`. Ro connection, table, export…), "dòng/cột", "khóa chính/khóa ngoại", "nhóm" for a team, "license" for the commercial key, never "giấy phép" except the AGPLv3. Spelling: khóa, xóa, hóa, tùy, hủy. NFC. - Same keys, same array lengths, same section and block ids, same `asset` ids and the same `{tokens}` as English - (`ContentParityTest`). Feature, tier, mode and engine names stay English. + (`TranslationsTest`). Feature, tier, mode and engine names stay English. - Click paths show the Vietnamese app label first: **Cài đặt > MCP** (Settings > MCP). ## Writing rules (sitemap §E.4, positioning §12) @@ -183,8 +183,8 @@ in a block's `engines`: `{ "label": "Plan as text", "list": "explainText" }`. Ro ## Checks ```bash -./vendor/bin/pest --compact tests/Feature/Features -./vendor/bin/pest --compact --filter='ContentParityTest|PaidFeaturesDataTest|LocaleRoutingTest|AssetManifestTest' +./vendor/bin/pest --compact tests/Feature/Pages/FeaturesTest.php +./vendor/bin/pest --compact --filter='TranslationsTest|PricingDataTest|LocaleRoutingTest|AssetManifestTest' npm run typecheck npm run test:js ``` diff --git a/resources/js/components/features/types.ts b/resources/js/components/features/types.ts index 0d2ad09c..4043dffa 100644 --- a/resources/js/components/features/types.ts +++ b/resources/js/components/features/types.ts @@ -2,10 +2,7 @@ * The feature pages' props and the shape of their content files. * * `App\Http\Controllers\FeatureController` sends these props. - * README.md in this directory documents the content schema for authors, and - * `tests/Feature/Features/FeatureContentTest.php` validates every file in - * `resources/data/content/{locale}/features/` against it, so the page can - * trust the shape without checking it at runtime. + * README.md in this directory documents the content schema for authors. */ /** One published engine in a list the page shows, from `engines.json`. */ diff --git a/resources/js/components/home/open-source-section.tsx b/resources/js/components/home/open-source-section.tsx index f0c787ab..11206bde 100644 --- a/resources/js/components/home/open-source-section.tsx +++ b/resources/js/components/home/open-source-section.tsx @@ -24,7 +24,7 @@ interface OpenSourceSectionProps { * * Product Hunt is a plain text link, by the owner's decision (spec §0): no * badge image and no hotlink, so the page makes no request to Product Hunt - * (`ThirdParty/ScriptsTest`). It sits after GitHub, away from the download + * (`Security/PrivacyTest`). It sits after GitHub, away from the download * actions. */ export default function OpenSourceSection({ content, availability }: OpenSourceSectionProps) { diff --git a/resources/js/components/home/types.ts b/resources/js/components/home/types.ts index d9aea015..fb664f22 100644 --- a/resources/js/components/home/types.ts +++ b/resources/js/components/home/types.ts @@ -1,7 +1,7 @@ /** * The props `App\Http\Controllers\HomeController` sends, and the type * of the page copy. The copy's shape is the English file's: - * `ContentParityTest` holds every other locale to the same keys. + * `TranslationsTest` holds every other locale to the same keys. */ import type { EngineCategory } from '@/lib/data/engines'; diff --git a/resources/js/components/pricing/types.ts b/resources/js/components/pricing/types.ts index 25fcd7bb..c57efd31 100644 --- a/resources/js/components/pricing/types.ts +++ b/resources/js/components/pricing/types.ts @@ -1,6 +1,6 @@ /** * The props the pricing components take from the server, and the copy types - * of `/pricing`. Copy shapes are the English files': `ContentParityTest` holds + * of `/pricing`. Copy shapes are the English files': `TranslationsTest` holds * every other locale to the same keys. */ export type PricingContent = typeof import('@data/content/en/pricing.json'); diff --git a/resources/js/components/site/support-banner.tsx b/resources/js/components/site/support-banner.tsx index 19e45f24..fa966c87 100644 --- a/resources/js/components/site/support-banner.tsx +++ b/resources/js/components/site/support-banner.tsx @@ -26,8 +26,7 @@ import { trackEvent } from '@/lib/analytics'; * * One 40px line at every width, never truncated: the sentence and its link * show from 1024px, and below that the short question and the same link, so a - * phone reader is still asked whether they are a regular user. TopBannerTest - * holds the catalog strings in every language to lengths that fit. The link + * phone reader is still asked whether they are a regular user. The link * takes the band's height as its target. * * It shares its slot with the language bar, which takes it over when it diff --git a/resources/js/components/sponsors/sponsor-grid.tsx b/resources/js/components/sponsors/sponsor-grid.tsx index 3d0a8db9..ba7cd91f 100644 --- a/resources/js/components/sponsors/sponsor-grid.tsx +++ b/resources/js/components/sponsors/sponsor-grid.tsx @@ -3,7 +3,6 @@ import CellGrid from '@/components/ui/cell-grid'; import type { LogoSponsor, SponsorLogo } from '@/lib/data/sponsors'; import { cn } from '@/lib/utils'; -// tests/Feature/Data/SponsorsDataTest.php reads both heights to keep raster logos near the size they are drawn at. const LOGO_HEIGHT = 28; const LEAD_LOGO_HEIGHT = 44; diff --git a/resources/js/i18n/messages/en/banner.ts b/resources/js/i18n/messages/en/banner.ts index 5fa7ca34..c33b1563 100644 --- a/resources/js/i18n/messages/en/banner.ts +++ b/resources/js/i18n/messages/en/banner.ts @@ -7,8 +7,7 @@ * pleads, and it never says the whole app is free. It cannot be closed, so * there is no "hide this" wording. * - * Length limits, held by TopBannerTest in every language, in display columns - * (a CJK character is two): the band is one 40px line and nothing in it may + * Length limits, in display columns (a CJK character is two): the band is one 40px line and nothing in it may * be truncated (design-system §3.3). From 1024px the line is `message` and * the `cta` link, together within 110, and `cta` within 20. Below 1024px * `short` asks the same question before the link, together within 34: 248px diff --git a/resources/js/i18n/messages/en/controls.ts b/resources/js/i18n/messages/en/controls.ts index 4241cbc4..0a514a2a 100644 --- a/resources/js/i18n/messages/en/controls.ts +++ b/resources/js/i18n/messages/en/controls.ts @@ -18,7 +18,7 @@ export default { * The bar that offers this page in the reader's language (LanguageBar), * written in that language and naming it, since the reader may not * read the page's: `action` is the link, `dismiss` the close button's - * name. One line on a 320px screen (LanguageBarTest). + * name. One line on a 320px screen. */ suggest: { label: 'Language suggestion', diff --git a/resources/js/i18n/paths.ts b/resources/js/i18n/paths.ts index 81810a61..183ab69c 100644 --- a/resources/js/i18n/paths.ts +++ b/resources/js/i18n/paths.ts @@ -14,7 +14,7 @@ import type { LocaleTable } from './types.ts'; // The paths nginx answers on this origin without this app: the account app's old paths redirect to // account.tablepro.app, and old unsubscribe links and Polar's webhook reach tablepro-cloud for 90 days // (docs/deploy/tablepro.app.nginx.conf). nginx matches them at the root only, so they never take a locale prefix. -// LocalizedUrlTest holds this equal to LocalizedUrl::PLATFORM_PATHS. +// LocaleRoutingTest holds this equal to LocalizedUrl::PLATFORM_PATHS. export const PLATFORM_PATHS = /^\/(account|checkout|thank-you|newsletter\/unsubscribe|webhooks\/polar)(\/|\?|#|$)/; export function isPlatformPath(path: string): boolean { diff --git a/resources/js/lib/data/brand.ts b/resources/js/lib/data/brand.ts index dedeaace..f1e894f4 100644 --- a/resources/js/lib/data/brand.ts +++ b/resources/js/lib/data/brand.ts @@ -33,5 +33,5 @@ export interface BrandData { colors: BrandColor[]; } -// The /brand page is English only, so its labels live here rather than in the locale catalogs. BrandDataTest validates the file. +// The /brand page is English only, so its labels live here rather than in the locale catalogs. SiteDataTest validates the file. export const BRAND = data as BrandData; diff --git a/resources/js/lib/data/paid-features.ts b/resources/js/lib/data/paid-features.ts index 15d0391e..7d98bc28 100644 --- a/resources/js/lib/data/paid-features.ts +++ b/resources/js/lib/data/paid-features.ts @@ -52,8 +52,7 @@ export interface PaidFeature { /** * The single cast from the JSON import, whose string unions TypeScript widens - * to `string`. `tests/Feature/Data/PaidFeaturesDataTest.php` validates the - * file and pins the names against the app. + * to `string`. `tests/Feature/Data/PricingDataTest.php` validates the file. */ export const PAID_FEATURES = data as PaidFeature[]; diff --git a/resources/js/lib/data/sponsors.ts b/resources/js/lib/data/sponsors.ts index 808f738c..d74a3f6b 100644 --- a/resources/js/lib/data/sponsors.ts +++ b/resources/js/lib/data/sponsors.ts @@ -36,5 +36,5 @@ export interface SponsorsData { sponsors: Sponsor[]; } -// tests/Feature/Data/SponsorsDataTest.php validates the file this casts. +// tests/Feature/Data/SiteDataTest.php validates the file this casts. export const SPONSORS = data as SponsorsData; diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index abdc9348..c5b9b159 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -301,7 +301,7 @@ function whenIdle() { those of everything it imports go out with the document. Otherwise the browser learns of them only once app.tsx has run and asked Inertia for the page, a round trip later, and hydration waits for it. - A component with no file (none today: SeoSmokeTest renders every page) + A component with no file (none today: Pages/PagesTest renders every page) is left to the runtime rather than failing the manifest lookup. The same goes for the page's UI catalog. English is inside app.tsx; diff --git a/routes/localized.php b/routes/localized.php index 30a5609e..95259c6b 100644 --- a/routes/localized.php +++ b/routes/localized.php @@ -40,7 +40,7 @@ | as a file would ship without a route. | 2. No root-level path may equal a locale prefix (`vi`), and `ios`, `features`, | `databases`, `compare`, `integrations`, `pricing` and `brand` must never -| join the database slugs. LocaleRoutingTest and LocalesDataTest guard both. +| join the database slugs. LocaleRoutingTest and SiteDataTest guard both. | 3. The blog and integration slugs stay patterns: whether a post or an | integration exists in the requested locale is the registry's and the | controller's decision. Integrations come from resources/data/integrations.json, diff --git a/tests/Feature/Assets/AssetManifestTest.php b/tests/Feature/Assets/AssetManifestTest.php index c8dc2095..28c8f10b 100644 --- a/tests/Feature/Assets/AssetManifestTest.php +++ b/tests/Feature/Assets/AssetManifestTest.php @@ -2,11 +2,8 @@ use App\Support\Assets\AssetManifest; use App\Support\Assets\AssetReferences; -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; @@ -22,9 +19,6 @@ function assetFixturePath(): string return dirname(__DIR__, 2) . '/Fixtures/assets/manifest.json'; } -/** - * @return array{kinds: array>, assets: array>} - */ function assetManifestData(string $path): array { return json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); @@ -39,9 +33,6 @@ function assetScratchDirectory(): string return $dir; } -/** - * @return list - */ function assetScratchDirectories(?string $add = null): array { static $dirs = []; @@ -142,9 +133,6 @@ function assetInstalledFixture(string $root): AssetManifest return assetSuppliedFixture($root); } -/** - * @return array> - */ function assetReferences(): array { static $references = null; @@ -152,9 +140,6 @@ function assetReferences(): array return $references ??= (new AssetReferences())->all(); } -/** - * @return list - */ function assetSlotFamilies(): array { $families = []; @@ -174,11 +159,11 @@ function assetSlotFamilies(): array } }); -describe('the visual contract', function (): void { - it('declares exactly the kinds of design-system §6.3, each with its export rules', function (): void { +describe('the manifest', function (): void { + it('gives every kind its export rules', function (): void { $kinds = (new AssetManifest())->kinds(); - expect(array_keys($kinds))->toBe(['window', 'mobile-crop', 'detail', 'phone', 'ipad', 'diagram', 'illustration', 'figure', 'og-card']); + expect($kinds)->not->toBeEmpty(); foreach ($kinds as $name => $kind) { foreach (['type', 'aspect', 'rendered', 'exportPx', 'vector', 'density', 'format', 'transparency', 'maxBytes', 'widths', 'sizes'] as $field) { @@ -205,36 +190,7 @@ function assetSlotFamilies(): array } }); - // 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 => [ - $kinds[$kind]['type'], - $kinds[$kind]['aspect'], - $kinds[$kind]['rendered'], - $kinds[$kind]['exportPx'], - $kinds[$kind]['density'], - ]; - - expect($pick('window'))->toBe(['screenshot', '16:9', ['desktop' => [1216, 684], 'tablet' => [720, 405], 'phone' => [343, 193]], [2432, 1368], 2]); - expect($pick('mobile-crop'))->toBe(['detail', '4:5', ['desktop' => null, 'tablet' => null, 'phone' => [343, 429]], [686, 858], 2]); - expect($pick('detail'))->toBe(['detail', '4:3', ['desktop' => [696, 522], 'tablet' => [720, 540], 'phone' => [343, 257]], [1392, 1044], 2]); - expect($pick('phone'))->toBe(['screenshot-phone', '9:19.5', ['desktop' => [280, 607], 'tablet' => [280, 607], 'phone' => [240, 520]], [1179, 2556], 3]); - expect($pick('ipad'))->toBe(['screenshot-ipad', '4:3', ['desktop' => [1008, 756], 'tablet' => [720, 540], 'phone' => [343, 257]], [2732, 2048], 2]); - expect($pick('figure'))->toBe(['per-figure', null, ['desktop' => [704, null], 'tablet' => [704, null], 'phone' => [343, null]], [1408, null], 2]); - expect($pick('og-card'))->toBe(['og-card', '1200:630', null, [1200, 630], 1]); - expect($kinds['diagram']['aspect'])->toBe('16:9'); - expect($kinds['illustration']['aspect'])->toBe('16:9'); - - expect(array_map(fn(array $kind): int => $kind['maxBytes'], $kinds))->toBe([ - 'window' => 250000, 'mobile-crop' => 120000, 'detail' => 150000, 'phone' => 150000, 'ipad' => 250000, - 'diagram' => 60000, 'illustration' => 250000, 'figure' => 200000, 'og-card' => 320000, - ]); - }); -}); - -describe('every entry', function (): void { - it('carries every handoff field of spec §9.1', function (): void { + it('gives every entry the fields the renderer reads', function (): void { $manifest = new AssetManifest(); $kinds = $manifest->kinds(); $fields = ['kind', 'type', 'family', 'ownerRepo', 'slot', 'handoffPriority', 'usedOn', 'aspect', 'priority', 'theme', 'locale', 'mobile', 'description', 'alt', 'caption', 'replacement', 'status', 'src', 'legacySource']; @@ -302,79 +258,7 @@ function assetSlotFamilies(): array Assert::assertNotSame('', trim($text), "{$id} has an empty {$field}.{$locale}"); } } - })->with(array_keys((json_decode((string) file_get_contents(__DIR__ . '/../../../resources/data/locales.json'), true))['supported'])); - - it('keeps the visible brief short, NFC and free of asset ids', function (): void { - $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) { - $text = $entry['description'][$locale] ?? null; - - if ($text === null) { - continue; - } - - if (mb_strlen($text) > $limit) { - $offences[] = "{$id}: description.{$locale} is " . mb_strlen($text) . " characters, over {$limit}"; - } - - foreach (array_keys($assets) as $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 && ! Normalizer::isNormalized($text, Normalizer::FORM_C)) { - $offences[] = "{$id}: {$field}.{$locale} is not NFC"; - } - } - } - } - - expect($assets)->not->toBeEmpty() - ->and($offences)->toBe([]); - }); - - // 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', - '/được giữ lại/u' => '"held back" as "kept"', - '/của nó|cho chúng/u' => 'an English possessive', - '/liên kết nhiều-nhiều/u' => 'a many-to-many relation, which the manifest calls "quan hệ nhiều-nhiều"', - '/kiểu đơn/u' => '"scalar", which stays in English', - ]; - - foreach ((new AssetManifest())->assets() as $id => $entry) { - foreach (['description', 'alt', 'caption'] as $field) { - $text = $entry[$field]['vi'] ?? null; - - if (! is_string($text)) { - continue; - } - - foreach ($misreadings as $pattern => $why) { - Assert::assertSame(0, preg_match($pattern, $text), "{$id}: {$field}.vi uses {$why}: {$text}"); - } - } - } - }); - - it('labels the Team Library crop by who last changed each query, as the accounts build does', function (): void { - // TeamLibraryQuery.detail is the last writer's name, or the folder (TablePro m16/accounts). - $entry = (new AssetManifest())->assets()['mac-team-library']; - - expect($entry['description']['en'])->toContain('labelled by who last changed them') - ->and($entry['alt']['en'])->toContain('labelled with the teammate who last changed it') - ->and($entry['description']['en'] . $entry['alt']['en'])->not->toContain('published'); - }); + })->with('locales'); it('places every entry on a page the site serves', function (): void { $routes = Route::getRoutes(); @@ -404,7 +288,7 @@ function assetSlotFamilies(): array } }); - it('gives every window a phone crop, and any other crop to a kind that shrinks on a phone', function (): void { + it('gives every window a phone crop, and every crop exactly one entry', function (): void { $assets = (new AssetManifest())->assets(); $crops = []; @@ -417,16 +301,11 @@ function assetSlotFamilies(): array $crop = $entry['mobile']; - // 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"); - } - if ($entry['kind'] !== 'window' && $crop === null) { continue; } - Assert::assertIsString($crop, "{$id}: a window needs a phone crop (design-system §6.3)"); + Assert::assertIsString($crop, "{$id}: a window needs a phone crop"); Assert::assertArrayHasKey($crop, $assets, "{$id} names the unknown crop {$crop}"); Assert::assertSame('mobile-crop', $assets[$crop]['kind'], "{$id}: {$crop} is not a mobile-crop"); Assert::assertArrayNotHasKey($crop, $crops, "{$crop} is the crop of two entries"); @@ -445,17 +324,6 @@ function assetSlotFamilies(): array } }); - it('gives priority only to one window and its crop', function (): void { - $priority = array_filter((new AssetManifest())->assets(), fn(array $entry): bool => $entry['priority']); - $windows = array_filter($priority, fn(array $entry): bool => $entry['kind'] === 'window'); - - expect(count($windows))->toBeLessThanOrEqual(1); - - foreach ($priority as $id => $entry) { - Assert::assertContains($entry['kind'], ['window', 'mobile-crop'], "{$id}: only the hero window and its crop load with priority"); - } - }); - it('names only existing files as legacy sources', function (): void { foreach ((new AssetManifest())->assets() as $id => $entry) { $legacy = $entry['legacySource']; @@ -703,43 +571,6 @@ function (string $root): AssetManifest { ]); }); - 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'); - - expect($committed)->toBeFile(); - Assert::assertSame($manifest->slotProjectionJson(), (string) file_get_contents($committed), 'resources/js/lib/data/asset-slots.json is stale. Run: php artisan assets:generate'); - - $projection = $manifest->slotProjection(); - $slots = array_keys(array_filter($manifest->assets(), fn(array $entry): bool => $entry['slot'])); - - expect(array_keys($projection['assets']))->toBe($slots); - - foreach ($projection['assets'] as $id => $entry) { - expect(array_keys($entry))->toBe(['kind', 'type', 'slot', 'aspect', 'priority', 'theme', 'locale', 'mobile', 'status', 'src', 'replacement'], $id); - } - - $minified = json_encode($projection, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); - - $metadataBytes = strlen((string) gzencode($minified, 9)); - - foreach (Locales::codes() as $locale) { - $path = resource_path('js/lib/data/asset-locales/' . $locale . '.json'); - expect($path)->toBeFile(); - Assert::assertSame($manifest->slotTextProjectionJson($locale), (string) file_get_contents($path), "Asset text for {$locale} is stale. Run: php artisan assets:generate"); - $copy = $manifest->slotTextProjection($locale); - expect(array_keys($copy))->toBe($slots); - $copyBytes = strlen((string) gzencode(json_encode($copy, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 9)); - expect($metadataBytes + $copyBytes)->toBeLessThan(18_432, "{$locale} downloads too much asset data"); - } - }); - - it('uses the same breakpoint as the art-directed picture', function (): void { - $model = (string) file_get_contents(resource_path('js/lib/data/asset-model.ts')); - - expect($model)->toContain("export const MOBILE_MEDIA = '" . AssetManifest::WIDE_MEDIA . "';"); - expect(AssetManifest::NARROW_MEDIA)->toBe('(max-width: 767.98px)'); - }); it('keeps the window preload behind its breakpoint while the crop is still a placeholder', function (): void { $root = assetScratchDirectory(); @@ -794,58 +625,5 @@ function (string $root): AssetManifest { 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's files are committed with its entry (Seo/BespokeOgCardTest). - $real = new AssetManifest(); - - foreach (['en', 'vi'] as $locale) { - $path = $locale === Locales::default() ? '/og.png' : "/og/bespoke/og-site-{$locale}.png"; - expect($real->ogCard('og-site', $locale))->toBe($real->isSupplied('og-site') ? $path : null); - } }); }); - -it('serves no image request inside a placeholder on any page', function (): void { - requireSsr(); - Http::fake([ - 'api.github.com/*' => Http::response(null, 404), - 'github.com/*' => Http::response(null, 404), - 'raw.githubusercontent.com/*' => Http::response(null, 404), - ]); - - $paths = []; - - foreach ((new AssetManifest())->assets() as $entry) { - foreach ($entry['slot'] ? $entry['usedOn'] : [] as $use) { - $paths[$use['path']] = true; - - if (! str_starts_with($use['path'], '/blog/')) { - $paths[rtrim('/vi' . $use['path'], '/')] = true; - } - } - } - - $found = 0; - - foreach (array_keys($paths) as $path) { - $response = $this->get($path); - - if ($response->getStatusCode() !== 200) { - continue; - } - - $document = HTMLDocument::createFromString((string) $response->getContent(), LIBXML_NOERROR); - - foreach ($document->querySelectorAll('[data-asset-status="placeholder"]') as $placeholder) { - $found++; - $id = $placeholder->getAttribute('data-asset-id'); - - Assert::assertCount(0, $placeholder->querySelectorAll('img, picture, source'), "{$path}: placeholder {$id} holds an image element"); - Assert::assertStringNotContainsString('background-image', (string) $placeholder->getAttribute('style'), "{$path}: placeholder {$id} sets a background image"); - Assert::assertStringNotContainsString('url(', $placeholder->innerHTML, "{$path}: placeholder {$id} can request a file"); - } - } - - // 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 01ce7506..6e4eb6e8 100644 --- a/tests/Feature/Assets/EmptySlotsTest.php +++ b/tests/Feature/Assets/EmptySlotsTest.php @@ -16,9 +16,6 @@ Http::fake(['api.github.com/*' => Http::response([], 500), 'github.com/*' => Http::response([], 500), 'raw.githubusercontent.com/*' => Http::response([], 500)]); }); -/** - * @return array> - */ function emptySlotPages(): array { $manifest = new AssetManifest(); @@ -87,18 +84,6 @@ function emptySlotPages(): array // Head included: no preload, Open Graph tag or JSON-LD node may name a file that is not there. Assert::assertStringNotContainsString($files, $html, "{$path} links a file of {$id}"); } - - Assert::assertCount(0, $main->querySelectorAll('div:empty, figure:empty'), "{$path} keeps an empty box where a slot was"); - - foreach ($main->querySelectorAll('[class~="lg:grid-cols-12"]') as $grid) { - $columns = 0; - - foreach ($grid->children as $cell) { - $columns += preg_match('/(?:^|\s)lg:col-span-(\d+)(?:\s|$)/', (string) $cell->getAttribute('class'), $span) === 1 ? (int) $span[1] : 1; - } - - Assert::assertSame(0, $columns % 12, "{$path}: a 12-column grid has a row with an empty column"); - } } } diff --git a/tests/Feature/Assets/ImageAssetsTest.php b/tests/Feature/Assets/ImageAssetsTest.php new file mode 100644 index 00000000..b93e355f --- /dev/null +++ b/tests/Feature/Assets/ImageAssetsTest.php @@ -0,0 +1,30 @@ +getExtension(), ['ts', 'tsx'], true)) { + preg_match_all('#/images/[A-Za-z0-9@._/-]+\.(?:png|webp|jpe?g|svg|avif)#', $file->getContents(), $matches); + + foreach ($matches[0] as $path) { + $referenced[$path] ??= $file->getRelativePathname(); + } + } + } + + foreach ((new AssetManifest())->assets() as $id => $entry) { + foreach ((array) ($entry['legacySource'] ?? []) as $path) { + $referenced[$path] ??= "assets.json ({$id})"; + } + } + + expect($referenced)->not->toBeEmpty(); + + foreach ($referenced as $path => $source) { + expect(public_path(ltrim($path, '/')))->toBeFile("{$path} referenced by {$source}"); + } +}); diff --git a/tests/Feature/Localization/RootTemplateTest.php b/tests/Feature/Assets/LcpPreloadTest.php similarity index 72% rename from tests/Feature/Localization/RootTemplateTest.php rename to tests/Feature/Assets/LcpPreloadTest.php index 28f9db11..b496c3f6 100644 --- a/tests/Feature/Localization/RootTemplateTest.php +++ b/tests/Feature/Assets/LcpPreloadTest.php @@ -1,6 +1,5 @@ ')); } -it('keeps overflow clipping off the document', function (): void { - $html = $this->get('/')->getContent(); - - preg_match('/]*>/', $html, $root); - preg_match('/]*>/', $html, $body); - - expect($root[0])->not->toContain('overflow-x-hidden'); - expect($body[0])->not->toContain('overflow-x-hidden'); -}); - -/** @return list> */ function runLcpScript(string $head, bool $dark): array { // Executed, not grepped: every source assertion once passed while browsers requested /undefined. @@ -85,19 +73,3 @@ function runLcpScript(string $head, bool $dark): array expect(headOf($this->get('/download')->getContent()))->not->toContain('imagesrcset'); }); - -it('builds the /ios preload with the sizes its iPad slot renders with', function (): void { - $manifest = new AssetManifest(base_path('tests/Fixtures/assets/manifest.json')); - $sizes = IosController::IPAD_SIZES; - - $preload = $manifest->lcpDescriptor('fixture-hero', 'en', priority: true, sizes: $sizes); - - // The main image takes the placement's sizes; the phone crop keeps its kind's, as asset-model.ts does. - expect($preload['light'][0]['sizes'])->toBe($sizes); - expect($preload['light'][1]['sizes'])->toBe('calc(100vw - 32px)'); - - $page = (string) file_get_contents(resource_path('js/pages/Ios.tsx')); - - expect($page)->toContain(''); - expect($page)->not->toContain('sizes="(min-width'); -}); diff --git a/tests/Feature/Chrome/IconsTest.php b/tests/Feature/Chrome/IconsTest.php deleted file mode 100644 index 776eac2d..00000000 --- a/tests/Feature/Chrome/IconsTest.php +++ /dev/null @@ -1,56 +0,0 @@ -get($path)->getContent(); - - expect($html)->toContain('') - ->toContain('') - ->toContain(''); - - expectSmallIcon('/logo.png', 256); - expectSmallIcon('/apple-touch-icon.png', 180); -})->with(['/', '/vi/pricing', '/no-such-page']); - -it('keeps every web manifest icon small and the size it claims', function (): void { - $manifest = json_decode((string) file_get_contents(public_path('site.webmanifest')), true, 512, JSON_THROW_ON_ERROR); - - expect($manifest['icons'])->not->toBeEmpty(); - - foreach ($manifest['icons'] as $icon) { - [$width] = explode('x', $icon['sizes']); - - expect($icon['type'])->toBe('image/png'); - expectSmallIcon($icon['src'], (int) $width); - } -}); - -it('keeps the logo the structured data and the error pages name', function (): void { - expect((string) file_get_contents(resource_path('js/lib/structured-data.ts')))->toContain("absoluteUrl(baseUrl, '/logo.png')") - ->and((string) file_get_contents(resource_path('views/errors/layout.blade.php')))->toContain('src="/logo.png"'); - - // Google's minimum for an organization logo is 112px square. - expectSmallIcon('/logo.png', 256); -}); - -it('keeps the mark in the header and footer of every page small', function (): void { - expect(filesize(public_path('images/logo.png')))->toBeLessThanOrEqual(12 * 1024); -}); diff --git a/tests/Feature/Chrome/LayoutRegressionTest.php b/tests/Feature/Chrome/LayoutRegressionTest.php deleted file mode 100644 index 8c5a4eb7..00000000 --- a/tests/Feature/Chrome/LayoutRegressionTest.php +++ /dev/null @@ -1,210 +0,0 @@ -]*>/s', $source, $tags) === 0) { - continue; - } - - foreach ($tags[0] as $tag) { - $checked++; - Assert::assertDoesNotMatchRegularExpression('/(?toBeGreaterThanOrEqual(7); -}); - -it('shows a visible table caption above the scroll region, so it never scrolls or clips', function (): void { - $table = layoutSource('js/components/ui/data-table.tsx'); - - expect($table)->toContain('
'); - Assert::assertLessThan(strpos($pair, '{iosAction}'), strpos($pair, '{mac}'), 'The Mac action comes first in the markup'); - - expect($menu)->toContain('
'); - - Assert::assertLessThan(strpos($template, 'toContain('with([ - 'js/components/home/platform-actions.tsx', - 'js/components/features/download-band.tsx', - 'js/components/databases/download-band.tsx', -]); - -it('server-renders the plan matrix with all four columns and no phone minimum width', function (): void { - $html = ssrHtml('/pricing'); - - preg_match('#
#s', $html, $section); - - // 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. - $markup = (string) preg_replace('##s', '', ssrHtml($path)); - - expect(str_contains($markup, 'Questions about this page?') || str_contains($markup, 'Bạn có câu hỏi về trang này?'))->toBe($repeats); -})->with([ - ['/privacy', false], - ['/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 { - // A native `disabled` dropped focus to mid request (WCAG 2.4.3). - $button = layoutSource('js/components/ui/button.tsx'); - - expect($button)->toContain('disabled={disabled}') - ->toContain('aria-disabled={(loading && !disabled) || undefined}') - ->toContain('onClick={loading ? (event: MouseEvent) => event.preventDefault() : onClick}'); - - foreach (['js/components/site/site-footer.tsx', 'js/components/blog/newsletter-signup.tsx'] as $form) { - expect(layoutSource($form))->toContain('readOnly={form.processing}')->not->toContain('disabled={form.processing}'); - } - - expect(layoutSource('js/hooks/use-email-form.ts'))->toContain("if (processing) {\n return;\n }"); -}); - -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. - $control = layoutSource('js/components/pricing/billing-cycle-control.tsx'); - - expect($control)->toContain('max-lg:[@media(min-height:40rem)]:sticky') - ->toContain('max-lg:top-16') - ->not->toContain('caption={'); - - // The caption keeps the height of the longest cycle's, so a tap on the pinned row moves no price. - expect($control)->toContain('className="type-small invisible col-start-1 row-start-1"'); - - // A focused control in a card must not scroll in under the pinned row; the row's own radios must not scroll at all. - expect(layoutSource('js/components/pricing/pricing-plans.tsx'))->toContain('max-lg:[&_:is(a,button,input:not([type=radio]),summary)]:scroll-mt-14'); - - expect(layoutSource('js/components/ui/segmented-control.tsx'))->toContain('pointer-coarse:min-h-11'); -}); - -it('totals the seats as they are typed, and says when a typed count was changed', function (): void { - // 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}') - ->toContain('onBlur={(event) => settle(event.target)}') - ->toContain('fmt(m.pricing.seats.clamped.min, { min })') - ->toContain('fmt(bound === \'max\' ? m.pricing.seats.clamped.max : m.pricing.seats.bounds, { min, max })'); -}); - -it('takes the Team seat maximum from GET /v2/pricing, and has none before it answers', function (): void { - expect(layoutSource('js/components/pricing/pricing-plans.tsx')) - ->toContain('const maxSeats = useTeamMaxSeats()[cycle] ?? null;') - ->toContain('maxSeats={maxSeats}'); - expect(layoutSource('js/components/pricing/pricing-card.tsx'))->toContain('max === null')->toContain('m.pricing.seats.minimum'); - expect(layoutSource('js/components/ui/stepper.tsx'))->toContain('max: number | null;'); - expect((string) file_get_contents(resource_path('data/pricing.json')))->not->toContain('"max"'); -}); - -it('drops a discount check whose code was edited while it ran', function (): void { - $field = layoutSource('js/components/pricing/discount-field.tsx'); - - expect($field)->toContain('const mine = ++ticket.current;') - ->toContain('if (!current()) {') - ->toContain('ticket.current++;'); -}); diff --git a/tests/Feature/Chrome/PageChunkTest.php b/tests/Feature/Chrome/PageChunkTest.php deleted file mode 100644 index e5f55111..00000000 --- a/tests/Feature/Chrome/PageChunkTest.php +++ /dev/null @@ -1,120 +0,0 @@ -> */ -function clientManifest(): array -{ - requireSsrJob(); - - $path = public_path('build/manifest.json'); - - if (! is_file($path)) { - ssrUnavailable('Client bundle missing. Run: npm run build'); - } - - return json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); -} - -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); - - expect($static[1])->toBe([Locales::default()]) - ->and($index)->toContain("import.meta.glob(['./messages/*/index.ts', '!./messages/" . Locales::default() . "/index.ts']") - ->and((string) file_get_contents(resource_path('js/resolve-page.ts')))->toContain('loadMessages(locale)'); -}); - -it('keeps every other language\'s catalog out of the entry chunk', function (): void { - $manifest = clientManifest(); - $entry = $manifest['resources/js/app.tsx']; - - foreach (array_diff(Locales::codes(), [Locales::default()]) as $locale) { - $catalog = "resources/js/i18n/messages/{$locale}/index.ts"; - - Assert::assertArrayHasKey($catalog, $manifest, "{$catalog} has no entry in public/build/manifest.json"); - Assert::assertTrue($manifest[$catalog]['isDynamicEntry'] ?? false, "{$catalog} is not a chunk of its own"); - 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(); - - $this->withVite(); - - $html = (string) $this->get($path)->assertOk()->getContent(); - - foreach (array_diff(Locales::codes(), [Locales::default()]) as $code) { - $script = '#s', $html, $scripts); - $script = collect($scripts[1])->first(fn(string $body): bool => str_contains($body, 'googletagmanager.com')); - - expect($script)->not->toBeNull(); - - $stub = 'globalThis.window = globalThis;' - . 'const added = []; let onLoad = null; let idle = null; let timeout = null;' - . 'globalThis.localStorage = { getItem: (key) => key === "tablepro:analytics-consent" ? "granted" : null };' - . 'globalThis.document = { readyState: ' . json_encode($readyState) . ', createElement: () => ({}), head: { appendChild: (node) => added.push(node.src) } };' - . 'globalThis.addEventListener = (type, listener) => { if (type === "load") { onLoad = listener; } };' - . ($idleCallback ? 'globalThis.requestIdleCallback = (callback, options) => { idle = [callback, options.timeout]; };' : '') - . 'globalThis.setTimeout = (callback, delay) => { timeout = [callback, delay]; };'; - - $run = 'const listenedForLoad = onLoad !== null;' - . 'const beforeLoad = [...added];' - . 'onLoad?.();' - . 'const afterLoad = [...added];' - . '(idle ?? timeout)?.[0]();' - . 'process.stdout.write(JSON.stringify({ beforeLoad, afterLoad, afterIdle: added, idleTimeout: idle?.[1] ?? null, timeoutDelay: timeout?.[1] ?? null, listenedForLoad, queue: window.dataLayer.map((args) => Array.from(args)) }));'; - - $result = Process::input($stub . $script . ';' . $run)->run(['node', '-']); - - expect($result->successful())->toBeTrue($result->errorOutput()); - - return json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR); -} - -it('requests Google\'s script only after the load event and an idle moment, with every call queued', function (bool $idleCallback): void { - $run = runAnalyticsScript(consentHtml('/download'), $idleCallback); - $tag = 'https://www.googletagmanager.com/gtag/js?id=G-TEST123'; - - expect($run['listenedForLoad'])->toBeTrue() - ->and($run['beforeLoad'])->toBe([]) - ->and($run['afterLoad'])->toBe([]) - ->and($run['afterIdle'])->toBe([$tag]); - - // The idle callback waits at most four seconds; Safari, which has none, waits two after the load. - expect($idleCallback ? $run['idleTimeout'] : $run['timeoutDelay'])->toBe($idleCallback ? 4000 : 2000); - - expect(array_column($run['queue'], 0))->toBe(['consent', 'consent', 'js', 'config']) - ->and($run['queue'][0][1])->toBe('default') - ->and($run['queue'][1])->toBe(['consent', 'update', ['analytics_storage' => 'granted']]) - ->and($run['queue'][3])->toBe(['config', 'G-TEST123']); -})->with(['with an idle callback' => true, 'without one (Safari)' => false]); - -it('waits only for idle when the page has already loaded', function (): void { - $run = runAnalyticsScript(consentHtml('/download'), true, 'complete'); - - expect($run['listenedForLoad'])->toBeFalse() - ->and($run['beforeLoad'])->toBe([]) - ->and($run['afterIdle'])->toBe(['https://www.googletagmanager.com/gtag/js?id=G-TEST123']); -}); - -it('renders no tag when no measurement id is configured', function (): void { - config(['analytics.google.measurement_id' => null]); - - $html = $this->get('/download')->assertOk()->getContent(); - - expect($html)->not->toContain('googletagmanager.com'); - expect($html)->not->toContain('gtag('); -}); - -it('denies storage before the tag is configured, and applies a stored answer in between', function (string $path): void { - $html = consentHtml($path); - - $default = strpos($html, "gtag('consent', 'default'"); - $stored = strpos($html, "gtag('consent', 'update', { analytics_storage: 'granted' })"); - $configured = strpos($html, "gtag('config'"); - - Assert::assertNotFalse($default, 'The tag must declare a consent default'); - Assert::assertNotFalse($stored, 'A returning reader who allowed analytics must be granted before the first hit'); - Assert::assertNotFalse($configured, 'The tag must be configured'); - Assert::assertLessThan($stored, $default, 'The default must come before the stored answer'); - Assert::assertLessThan($configured, $stored, 'The stored answer must be applied before the tag is configured'); - - $defaults = substr($html, $default, $stored - $default); - - foreach (['ad_storage', 'ad_user_data', 'ad_personalization', 'analytics_storage'] as $signal) { - Assert::assertStringContainsString("{$signal}: 'denied'", $defaults, "{$signal} must default to denied"); - } -})->with(['/download', '/vi/download']); - -it('never grants an advertising signal', function (): void { - $sources = consentSource('resources/views/app.blade.php') . consentSource('resources/js/lib/consent.ts'); - - foreach (['ad_storage', 'ad_user_data', 'ad_personalization'] as $signal) { - expect($sources)->not->toMatch("/{$signal}:\\s*'granted'/"); - } -}); - -it('reads the answer under the key the consent module writes', function (): void { - preg_match("/CONSENT_STORAGE_KEY = '([^']+)'/", consentSource('resources/js/lib/consent.ts'), $key); - - Assert::assertSame('tablepro:analytics-consent', $key[1] ?? null); - Assert::assertStringContainsString( - "localStorage.getItem('{$key[1]}')", - consentSource('resources/views/app.blade.php'), - 'app.blade.php must read the key consent.ts writes', - ); -}); - -it('leaves nothing of Plausible behind', function (): void { - foreach (['resources/views/app.blade.php', 'config/analytics.php', '.env.example'] as $file) { - Assert::assertFalse(stripos(consentSource($file), 'plausible'), "{$file} still mentions Plausible"); - } - - expect(consentSource('resources/js/lib/analytics.ts'))->not->toMatch('/plausible\s*\(|\.plausible\b/'); - - foreach (['en', 'vi'] as $locale) { - $file = "resources/data/legal/{$locale}/privacy.md"; - - Assert::assertFalse(stripos(consentSource($file), 'plausible'), "{$file} still mentions Plausible"); - } -}); - -it('asks on every page and lets the reader change their answer from any of them', function (): void { - $layout = consentSource('resources/js/layouts/landing-layout.tsx'); - $wrapper = consentSource('resources/js/components/site/consent-bar.tsx'); - $footer = consentSource('resources/js/components/site/site-footer.tsx'); - - expect($layout)->toContain('') - ->toContain("import ConsentBar from '@/components/site/consent-bar';") - // A flex column, so the bar's first ask on a phone can sit above the header. - ->toContain('
toContain('labels={m.consent}') - ->toContain("path('/privacy#cookies')"); - - expect($footer)->toMatch('/
#s', ssrHome($path), $section); - - expect($open)->toBeGreaterThan(0, 'The invite cell is hidden when every place is taken; this test needs an open one'); - expect(substr_count($section[0] ?? '', 'toBe($placed + 1); - expect(substr_count($section[0] ?? '', "href=\"{$prefix}/sponsors\""))->toBe(2, 'The heading link and the invite cell'); - expect($section[0] ?? '')->toContain(htmlspecialchars($invite['title'], ENT_QUOTES)) - ->toContain(str_replace(['{count}', '{total}'], [(string) $open, (string) $data['homepageSlots']], $invite['open'][$open === 1 ? 'one' : 'other'])) - ->not->toContain('github.com/sponsors'); - })->with([['/', ''], ['/vi', '/vi']])->group('ssr'); - - it('renders a title built from platforms.json, Product Hunt only as a text link, and wraps the sponsor logos', function (string $path, string $title, string $productHunt): void { - $html = ssrHome($path); - - expect($html)->toMatch('#]*>' . preg_quote($title, '#') . '#u'); - expect(mb_strlen($title))->toBeLessThanOrEqual(60); - - // One anchor in #open-source is the only place Product Hunt appears: no image, no script, no preconnect. - preg_match('#]*id="open-source".*?#s', $html, $openSource); - preg_match_all('#]*\bhref="https://www\.producthunt\.com/products/tablepro"[^>]*>(.*?)#s', $openSource[0] ?? '', $anchors); - - expect($anchors[0])->toHaveCount(1); - expect(strip_tags($anchors[1][0]))->toContain($productHunt); - expect($anchors[1][0])->not->toContain('toBe(1); - // No toast region: nothing calls toast(), and Sonner's English landmark name showed on /vi. - expect($html)->not->toContain('aria-label="Notifications'); - - // A wide wordmark once overflowed at 375px, and a late lazy load left the white dark-mode tile empty. - preg_match('#]*id="sponsors".*?#s', $html, $sponsors); - - expect($sponsors[0] ?? '')->toContain('cell-grid grid-cols-2 md:grid-cols-4')->toContain('min-w-0 max-w-full') - ->toContain('fetchPriority="low"')->not->toContain('loading="lazy"'); - })->with([ - ['/', 'TablePro: native database client for Mac, iPhone and iPad', 'TablePro on Product Hunt'], - ['/vi', 'TablePro: database client native cho Mac, iPhone và iPad', 'TablePro trên Product Hunt'], - ])->group('ssr'); - - it('links the features hub from the workflows section and scopes the importers to the Mac app', function (string $path, string $lead): void { - $html = ssrHome($path); - $start = (int) strpos($html, 'id="features"'); - $workflows = substr($html, $start, (int) strpos($html, 'id="safety"') - $start); - $prefix = $path === '/vi' ? '/vi' : ''; - - expect($workflows)->toMatch('#href="' . $prefix . '/features"#'); - expect($html)->toContain($lead); - - // The row that describes the Structure tab also links the page that covers it. - $from = strpos($workflows, 'id="features-edit"'); - $to = strpos($workflows, 'id="features-schema"'); - - expect($from)->not->toBeFalse(); - expect($to)->not->toBeFalse(); - - $edit = substr($workflows, (int) $from, (int) $to - (int) $from); - - expect($edit)->toContain('href="' . $prefix . '/features/data-editing"')->toContain('href="' . $prefix . '/features/schema#structure"'); - - // What the reader can bring leads the section; the Mac-only note closes its text. - $switchStart = strpos($html, 'id="switch"'); - $switch = substr($html, (int) $switchStart, (int) strpos($html, 'id="pricing"') - (int) $switchStart); - $note = strpos($switch, $lead); - $project = strpos($switch, 'docker-compose.yml'); - - expect($switchStart)->not->toBeFalse(); - expect($note)->not->toBeFalse("{$path}: the Mac-only note is missing from #switch"); - expect($project)->not->toBeFalse(); - expect($project)->toBeLessThan($note); - })->with([ - ['/', 'Importing from other apps and Open Project Folder are in the Mac app only.'], - ['/vi', 'Import từ ứng dụng khác và Open Project Folder chỉ có trong ứng dụng Mac.'], - ])->group('ssr'); - - it('sets each workflow beside its capture from 1280px, with one divider down the section', function (string $path, string $locale): void { - $grid = HTMLDocument::createFromString(ssrHome($path), LIBXML_NOERROR)->querySelector('#features .cell-grid'); - $classes = fn(?Element $element): array => preg_split('/\s+/', trim((string) $element?->getAttribute('class'))); - $manifest = app(AssetManifest::class); - $rows = homeContent($locale)['workflows']['rows']; - - expect($grid)->not->toBeNull(); - expect($grid->querySelectorAll('h3'))->toHaveCount(count($rows)); - - foreach ($rows as $row) { - $heading = $grid->querySelector("#features-{$row['id']}"); - $cell = $heading; - - while ($cell->parentElement !== $grid) { - $cell = $cell->parentElement; - } - - if ($manifest->entry($row['asset'])['kind'] === 'detail') { - // Two cells, 5 / 7 from 1024px and 4 / 8 from 1280px, so the divider meets the window rows'. - expect($classes($cell))->toContain('lg:col-span-5', 'xl:col-span-4'); - expect($classes($cell->nextElementSibling))->toContain('lg:col-span-7', 'xl:col-span-8'); - - continue; - } - - // One cell at every width, so the stacked row below 1280px keeps no line between its text and its window. - [$text, $media] = [$cell->firstElementChild, $cell->lastElementChild]; - - expect($classes($cell))->toContain('lg:col-span-12', 'xl:grid', 'xl:grid-cols-12', 'xl:gap-px', 'xl:p-0'); - expect(array_values(array_filter($classes($cell), fn(string $class): bool => str_contains($class, 'grid') && ! str_starts_with($class, 'xl:'))))->toBe([]); - expect($cell->childElementCount)->toBe(2); - expect($text->contains($heading))->toBeTrue(); - expect($classes($text))->toContain('xl:col-span-4'); - expect($classes($media))->toContain('xl:col-span-8', 'xl:shadow-[-1px_0_0_var(--rule)]'); - - // The desktop sources load the width the window has in its eight columns. - $sources = $media->querySelectorAll('source[media="(min-width: 768px)"]'); - - expect($sources->length)->toBeGreaterThan(0, "{$path}: {$row['asset']} renders no desktop source"); - - foreach ($sources as $source) { - expect($source->getAttribute('sizes'))->toStartWith('(min-width: 1280px) 790px,'); - } - } - - // 12 columns share the 1278px grid (76rem of content and a 31px bleed each side) with 1px between them; a window spans 8 and pads 31px each side. - $column = (76 * 16 + 2 * 31 - 11) / 12; - - expect((int) round(8 * $column + 7 - 2 * 31))->toBe(790); - })->with([['/', 'en'], ['/vi', 'vi']])->group('ssr'); - - it('shows each featured engine once, the engine count from data and the hub\'s categories', function (string $path, string $locale): void { - $html = ssrHome($path); - preg_match('#]*id="databases".*?#s', $html, $match); - $section = $match[0] ?? ''; - $engines = collect(homeData('engines.json'))->where('state', 'published'); - $categories = array_values(array_intersect(DatabaseController::CATEGORIES, $engines->pluck('category')->all())); - $prefix = $locale === 'en' ? '' : "/{$locale}"; - - expect($section)->not->toBe(''); - expect($categories)->not->toBeEmpty(); - - foreach ($engines->where('featured', true) as $engine) { - expect(substr_count($section, 'href="' . $prefix . '/' . $engine['slug'] . '"'))->toBe(1, "{$path}: {$engine['name']} is not linked exactly once in #databases"); - } - - // Every link in the block is a featured engine or a way into the hub: the full list lives on /databases. - preg_match_all('#]*\bhref="([^"]+)"#', $section, $links); - - expect(count($links[1]))->toBe($engines->where('featured', true)->count() + count($categories) + 1); - - foreach ($categories as $category) { - $title = homeData("content/{$locale}/databases/index.json")['categories'][$category]['title']; - - expect($section)->toMatch('~]*href="' . preg_quote("{$prefix}/databases#{$category}", '~') . '"[^>]*>' . preg_quote(htmlspecialchars($title, ENT_QUOTES), '~') . '~u'); - } - - expect(strip_tags($section))->toContain((string) $engines->count()); - })->with([['/', 'en'], ['/vi', 'vi']])->group('ssr'); - - it('renders the concise supported-databases heading', function (): void { - expect(ssrHome('/'))->toMatch('#

]*>Supported databases

#'); - })->group('ssr'); - - it('puts what native means under the hero actions, outside the headline', function (string $path, string $locale): void { - $html = ssrHome($path); - $copy = homeContent($locale)['hero']; - $start = strpos($html, 'id="top"'); - $hero = html_entity_decode(strip_tags(substr($html, (int) $start, (int) strpos($html, 'id="databases"') - (int) $start)), ENT_QUOTES | ENT_HTML5); - preg_match('#(.*?)#', $copy['business'], $pricingLink); - - $headline = strpos($hero, $copy['title']); - $pricing = strpos($hero, $pricingLink[1]); - $native = strpos($hero, $copy['native']); - - expect($start)->not->toBeFalse(); - expect($headline)->not->toBeFalse(); - expect($pricing)->not->toBeFalse("{$path}: the pricing line is not in the hero"); - expect($native)->not->toBeFalse("{$path}: the native line is not in the hero"); - expect($pricing)->toBeGreaterThan($headline); - expect($native)->toBeGreaterThan($pricing); - })->with([['/', 'en'], ['/vi', 'vi']])->group('ssr'); - - it('shows the hero window as a labelled placeholder, and preloads no image but the logo, until it is supplied', function (): void { - if (app(AssetManifest::class)->isSupplied('mac-hero-window')) { - $this->markTestSkipped('The hero is supplied.'); - } - - $html = ssrHome('/'); - preg_match_all('#toContain('data-asset-id="mac-hero-window"') - ->toContain('data-asset-id="mac-hero-window-mobile"'); - // The Blade block that preloads the hero writes `imagesrcset`. - expect($html)->not->toContain('imagesrcset'); - // React preloads the header logo it renders on every page; nothing else may be preloaded. - expect(array_values(array_diff($preloads[1], ['/images/logo.png'])))->toBe([]); - })->group('ssr'); - - it('shows each post in the reader\'s language, with the original behind a labelled disclosure, the author\'s photo and a link to the post', function (string $path, string $locale): void { - $section = HTMLDocument::createFromString(ssrHome($path), LIBXML_NOERROR)->querySelector('#testimonials'); - $labels = homeContent($locale)['testimonials']['translatedFrom']; - $flat = fn(string $text): string => (string) preg_replace('/\s+/u', '', $text); - - expect($section)->not->toBeNull(); - - $items = $section->querySelectorAll('ul > li'); - - expect($items)->toHaveCount(Testimonials::SHOWN); - - foreach (app(Testimonials::class)->forLocale($locale) as $index => $quote) { - $item = $items->item($index); - $blockquote = $item->querySelector('blockquote'); - $details = $item->querySelector('details'); - - if ($quote['translation'] === null) { - expect($blockquote->getAttribute('lang'))->toBe($quote['lang'], "{$path} {$quote['id']}"); - expect($flat($blockquote->textContent))->toBe($flat($quote['text']), "{$path} {$quote['id']} is not the text as written"); - expect($details)->toBeNull(); - } else { - expect($blockquote->hasAttribute('lang'))->toBeFalse("{$path} {$quote['id']}: the translation is in the page's language"); - expect($flat($blockquote->textContent))->toBe($flat($quote['translation'])); - expect($details?->querySelector('summary')?->textContent)->toBe($labels[$quote['translatedFrom']]); - expect($flat((string) $details?->querySelector("[lang=\"{$quote['lang']}\"]")?->textContent))->toBe($flat($quote['text']), "{$path} {$quote['id']}: the original is one click away"); - } - - expect($item->querySelector('[translate="no"]')->textContent)->toBe($quote['name']); - expect($item->querySelector('a[href="' . $quote['url'] . '"]'))->not->toBeNull("{$path} {$quote['id']} does not link its post"); - - $photo = $item->querySelector('img'); - expect($photo?->getAttribute('src'))->toBe("/images/testimonials/{$quote['id']}.webp"); - expect($photo?->getAttribute('alt'))->toBe('', 'the name beside the photo already says who it is'); - } - })->with([ - ['/', 'en'], - ['/ja', 'ja'], - ['/vi', 'vi'], - ['/zh-Hant', 'zh-Hant'], - ])->group('ssr'); - - it('says who builds TablePro in the open-source section, and links the about page, in every language', function (string $locale): void { - $publisher = homeData('facts.json')['publisher']; - - preg_match('#]*id="open-source".*?#s', ssrHome(LocalizedUrl::path('/', $locale)), $section); - - $text = (string) preg_replace('/[\x{00A0}\x{202F}]/u', ' ', html_entity_decode(strip_tags(str_replace('', '', $section[0] ?? '')), ENT_QUOTES | ENT_HTML5)); - - expect($text)->toContain($publisher['name'])->toContain($publisher['city'][$locale]); - expect($section[0] ?? '')->toContain('href="' . LocalizedUrl::path('/about', $locale) . '"'); - })->with(homeLocales())->group('ssr'); - - it('marks up no quote as a review or a rating', function (): void { - expect(ssrHome('/'))->toContain('id="testimonials"')->not->toContain('"Review"')->not->toContain('aggregateRating'); - })->group('ssr'); -}); diff --git a/tests/Feature/Landing/ImageAssetsTest.php b/tests/Feature/Landing/ImageAssetsTest.php deleted file mode 100644 index d3779c85..00000000 --- a/tests/Feature/Landing/ImageAssetsTest.php +++ /dev/null @@ -1,150 +0,0 @@ - - */ -function frontendSourceFiles(): array -{ - $files = []; - $tree = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator(resource_path('js'), FilesystemIterator::SKIP_DOTS), - ); - - foreach ($tree as $file) { - if (in_array($file->getExtension(), ['ts', 'tsx'], true)) { - $files[] = $file->getPathname(); - } - } - - return $files; -} - -/** - * @return list - */ -function publicImageFiles(): array -{ - $files = []; - $tree = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator(public_path('images'), FilesystemIterator::SKIP_DOTS), - ); - - foreach ($tree as $file) { - if (in_array(strtolower($file->getExtension()), ['png', 'webp', 'avif', 'jpg', 'jpeg'], true)) { - $files[] = $file->getPathname(); - } - } - - sort($files); - - return $files; -} - -/** - * @return array - */ -function referencedImagePaths(): array -{ - $paths = []; - - foreach (frontendSourceFiles() as $file) { - preg_match_all('#/images/[A-Za-z0-9@._/-]+\.(?:png|webp|jpe?g|svg|avif)#', (string) file_get_contents($file), $matches); - - foreach ($matches[0] as $path) { - $paths[$path] ??= str_replace(base_path() . '/', '', $file); - } - } - - foreach ((new AssetManifest())->assets() as $id => $entry) { - foreach ((array) ($entry['legacySource'] ?? []) as $path) { - $paths[$path] ??= "resources/data/assets.json ({$id})"; - } - } - - ksort($paths); - - return $paths; -} - -/** - * @return array{0: int, 1: int} - */ -function imageDimensions(string $absolutePath): array -{ - $size = @getimagesize($absolutePath); - - if ($size === false) { - return [0, 0]; - } - - return [$size[0], $size[1]]; -} - -it('serves every image the frontend or the asset manifest names', function (): void { - $missing = []; - $referenced = referencedImagePaths(); - - expect($referenced)->not->toBeEmpty(); - - foreach ($referenced as $path => $source) { - if (! is_file(public_path(ltrim($path, '/')))) { - $missing[] = "{$path} referenced by {$source}"; - } - } - - expect($missing)->toBe([]); -}); - -it('gives every ladder derivative the width in its filename', function (): void { - $wrong = []; - $checked = 0; - - foreach (publicImageFiles() as $absolute) { - if (preg_match('#-(\d+)\.(?:webp|avif)$#', $absolute, $match) !== 1) { - continue; - } - - [$actual] = imageDimensions($absolute); - $checked++; - - if ($actual !== (int) $match[1]) { - $name = basename($absolute); - $wrong[] = "{$name} is {$actual}px wide"; - } - } - - expect($wrong)->toBe([]); - Assert::assertGreaterThan(0, $checked, 'No ladder derivative found under public/images: the filename pattern no longer matches anything.'); -}); - -it('shoots both themes of a pair at one size', function (): void { - $mismatched = []; - $checked = 0; - - foreach (publicImageFiles() as $absolute) { - if (! str_contains($absolute, '-light')) { - continue; - } - - $dark = str_replace('-light', '-dark', $absolute); - - if (! is_file($dark)) { - continue; - } - - $lightSize = imageDimensions($absolute); - $darkSize = imageDimensions($dark); - $checked++; - - if ($lightSize !== $darkSize) { - $name = basename($absolute); - $mismatched[] = "{$name} is {$lightSize[0]}x{$lightSize[1]} but its dark variant is {$darkSize[0]}x{$darkSize[1]}"; - } - } - - expect($mismatched)->toBe([]); - Assert::assertGreaterThan(0, $checked, 'No light and dark pair found under public/images.'); -}); diff --git a/tests/Feature/Landing/InternalLinksTest.php b/tests/Feature/Landing/InternalLinksTest.php deleted file mode 100644 index 32bb38cc..00000000 --- a/tests/Feature/Landing/InternalLinksTest.php +++ /dev/null @@ -1,329 +0,0 @@ - Http::response([], 200)]); -}); - -const INTERNAL_LINKS_PLATFORM = ['account', 'checkout', 'thank-you', 'newsletter', 'webhooks']; - -const INTERNAL_LINKS_SYSTEM = ['/robots.txt', '/sitemap.xml', '/.well-known/security.txt']; - -/** - * @return array{path: string, query: string, fragment: string}|null - */ -function internalLinkTarget(string $href, string $page): ?array -{ - $href = trim($href); - - if ($href === '' || preg_match('/^(mailto|tel|sms):/i', $href) === 1) { - return null; - } - - if (str_starts_with($href, '#')) { - return ['path' => $page, 'query' => '', 'fragment' => substr($href, 1)]; - } - - $parts = parse_url($href); - - if ($parts === false) { - return ['path' => $href, 'query' => '', 'fragment' => '']; - } - - if (isset($parts['host']) || isset($parts['scheme'])) { - $own = [parse_url(LocalizedUrl::base(), PHP_URL_HOST), 'tablepro.app', 'www.tablepro.app', config('app.web_domain'), 'localhost']; - - if (! in_array(strtolower((string) ($parts['host'] ?? '')), array_map('strtolower', array_filter($own)), true)) { - return null; - } - } - - return [ - 'path' => ($parts['path'] ?? '') === '' ? '/' : $parts['path'], - 'query' => $parts['query'] ?? '', - 'fragment' => $parts['fragment'] ?? '', - ]; -} - -/** - * @param array{path: string, query: string, fragment: string} $target - * @param callable(string): int $status - * @param callable(string): (list|null) $ids - */ -function internalLinkProblem(array $target, string $locale, callable $status, callable $ids): ?string -{ - $path = $target['path']; - $segments = explode('/', ltrim($path, '/')); - - if (! str_starts_with($path, '/')) { - return 'is relative; internal links start at the root'; - } - - if (count($segments) > 1 && in_array($segments[0], ['vi', 'en'], true) && in_array($segments[1], INTERNAL_LINKS_PLATFORM, true)) { - return "puts the account app under a locale prefix; link /{$segments[1]} with ?locale={$locale}"; - } - - if (in_array($segments[0], INTERNAL_LINKS_PLATFORM, true)) { - if ($segments[0] !== 'account') { - return null; - } - - parse_str($target['query'], $query); - - return ($query['locale'] ?? null) === $locale ? null : "reaches the account app without ?locale={$locale}"; - } - - if (in_array($path, INTERNAL_LINKS_SYSTEM, true)) { - return null; - } - - if (preg_match('/\.[a-z0-9]{2,5}$/i', $path) === 1) { - return is_file(public_path(ltrim($path, '/'))) ? null : 'names a file that public/ does not have'; - } - - $retired = app(RedirectMap::class)->find($path); - - if ($retired !== null) { - return $retired['status'] === 301 ? "goes through a 301 to {$retired['to']}; link the target" : "links a page that answers {$retired['status']}"; - } - - $url = $target['query'] === '' ? $path : "{$path}?{$target['query']}"; - $code = $status($url); - - if ($code !== 200) { - return "answers {$code}"; - } - - if ($target['fragment'] !== '') { - $known = $ids($path); - - if ($known !== null && ! in_array(rawurldecode($target['fragment']), $known, true)) { - return "has no element with id=\"{$target['fragment']}\""; - } - } - - return null; -} - -/** - * @return array - */ -function internalLinkDefaultUrls(): array -{ - $entries = []; - - foreach (app(PageRegistry::class)->all() as $entry) { - if ($entry->renders(Locales::default())) { - $entries[$entry->url(Locales::default(), false)] = $entry; - } - } - - return $entries; -} - -/** - * @param array{path: string, query: string, fragment: string} $target - * @param array $defaultUrls - */ -function internalLinkLocaleProblem(array $target, string $locale, bool $declaresLanguage, array $defaultUrls): ?string -{ - if ($locale === Locales::default() || $declaresLanguage) { - return null; - } - - $entry = $defaultUrls[$target['path']] ?? null; - - if ($entry === null || ! $entry->renders($locale)) { - return null; - } - - $own = $entry->url($locale, false); - - return $own === $target['path'] ? null : "leaves the reader's language; link {$own}"; -} - -/** - * @return array - */ -function internalLinkPages(): array -{ - $pages = []; - - foreach (app(PageRegistry::class)->all() as $entry) { - foreach ($entry->renderLocales as $locale) { - $pages[$entry->url($locale, false)] = $locale; - } - } - - return $pages; -} - -/** - * @param array $props - * @return list - */ -function internalLinkPropHrefs(array $props): array -{ - $hrefs = []; - - array_walk_recursive($props, function (mixed $value, int|string $key) use (&$hrefs): void { - if (! is_string($value)) { - return; - } - - if ($key === 'href' && preg_match('#^(/|\#)#', $value) === 1) { - $hrefs[] = $value; - } - - preg_match_all('/\bhref="([^"]+)"|\]\(((?:\/|\#)[^)\s]*)\)/', $value, $matches); - - foreach ([...$matches[1], ...$matches[2]] as $href) { - if ($href !== '') { - $hrefs[] = html_entity_decode($href, ENT_QUOTES | ENT_HTML5); - } - } - }); - - return array_values(array_unique($hrefs)); -} - -/** - * @param array $known - * @return callable(string): int - */ -function internalLinkStatuses(array &$known): callable -{ - return function (string $url) use (&$known): int { - return $known[$url] ??= test()->get($url)->getStatusCode(); - }; -} - -it('links only live pages from what the server hands every page', function (): void { - $crawl = seoCrawlProps(); - $statuses = array_map(fn(array $page): int => $page['status'], $crawl); - $problems = []; - $checked = 0; - - foreach (internalLinkPages() as $path => $locale) { - foreach (internalLinkPropHrefs($crawl[$path]['props']) as $href) { - $target = internalLinkTarget($href, $path); - - if ($target === null) { - continue; - } - - $checked++; - $problem = internalLinkProblem($target, $locale, internalLinkStatuses($statuses), fn(): ?array => null); - - if ($problem !== null) { - $problems["{$path} → {$href}"] = "{$path} → {$href} {$problem}"; - } - } - } - - expect($checked)->toBeGreaterThan(200) - ->and(array_values($problems))->toBe([], "Broken internal links in page props:\n " . implode("\n ", $problems)); -}); - -it('links only live pages and real anchors from every server-rendered page', function (): void { - $pages = internalLinkPages(); - seoCrawlHtml(); - $statuses = []; - $ids = []; - $links = []; - - $idsIn = fn(HTMLDocument $document): array => array_map( - fn(Dom\Element $element): string => (string) $element->getAttribute('id'), - iterator_to_array($document->querySelectorAll('[id]')), - ); - - foreach ($pages as $path => $locale) { - $document = HTMLDocument::createFromString(seoCrawledHtml($path), LIBXML_NOERROR); - $statuses[$path] = 200; - $ids[$path] = $idsIn($document); - $links[$path] = []; - - foreach ($document->querySelectorAll('a[href], area[href]') as $link) { - $links[$path][] = [(string) $link->getAttribute('href'), $link->hasAttribute('hreflang')]; - } - } - - $idsOf = function (string $path) use (&$ids, $idsIn): ?array { - if (! array_key_exists($path, $ids)) { - $response = test()->get($path); - - $ids[$path] = $response->getStatusCode() === 200 - ? $idsIn(HTMLDocument::createFromString((string) $response->getContent(), LIBXML_NOERROR)) - : null; - } - - return $ids[$path]; - }; - - $problems = []; - $checked = 0; - $defaultUrls = internalLinkDefaultUrls(); - - foreach ($pages as $path => $locale) { - foreach ($links[$path] as [$href, $declaresLanguage]) { - $target = internalLinkTarget($href, $path); - - if ($target === null) { - continue; - } - - $checked++; - // The language rule needs the rendered HTML: content stores unprefixed paths and adds the prefix. - $problem = internalLinkProblem($target, $locale, internalLinkStatuses($statuses), $idsOf) - ?? internalLinkLocaleProblem($target, $locale, $declaresLanguage, $defaultUrls); - - if ($problem !== null) { - $problems["{$path} → {$href}"] = "{$path} → {$href} {$problem}"; - } - } - } - - expect($checked)->toBeGreaterThan(1000) - ->and(array_values($problems))->toBe([], "Broken internal links in the rendered pages:\n " . implode("\n ", $problems)); -})->group('ssr'); - -it('judges each kind of link the way the crawl needs', function (string $href, string $locale, ?string $problem): void { - $statuses = ['/pricing' => 200, '/vi/pricing' => 200, '/nowhere' => 404]; - $status = fn(string $url): int => $statuses[strtok($url, '?')] ?? 200; - $ids = fn(string $path): ?array => $path === '/pricing' ? ['plans', 'faq'] : null; - $target = internalLinkTarget($href, '/pricing'); - - $found = $target === null ? null : internalLinkProblem($target, $locale, $status, $ids); - - expect($found === null ? null : explode(';', $found)[0])->toBe($problem); -})->with([ - 'a missing page' => ['/nowhere', 'en', 'answers 404'], - 'a retired page' => ['/mariadb-client', 'en', 'goes through a 301 to /mysql-client#mariadb'], - 'a removed page' => ['/compare/azimutt', 'en', 'links a page that answers 410'], - 'a missing anchor' => ['#nope', 'en', 'has no element with id="nope"'], - 'the account in the wrong language' => ['/account?locale=en', 'vi', 'reaches the account app without ?locale=vi'], - 'the account under a prefix' => ['/vi/account?locale=vi', 'vi', 'puts the account app under a locale prefix'], - 'an absolute link to this site' => ['https://tablepro.app/nowhere', 'en', 'answers 404'], - 'a relative link' => ['pricing', 'en', 'is relative'], - 'a missing file' => ['/images/nowhere.png', 'en', 'names a file that public/ does not have'], -]); - -it('keeps a reader in their language when a page links a page that exists in it', function (string $href, string $page, string $locale, bool $hreflang, ?string $problem): void { - $target = internalLinkTarget($href, $page); - $found = internalLinkLocaleProblem($target, $locale, $hreflang, internalLinkDefaultUrls()); - - expect($found === null ? null : explode(';', $found)[0])->toBe($problem); -})->with([ - 'the English twin from a Vietnamese page' => ['/pricing', '/vi', 'vi', false, "leaves the reader's language"], - 'the language switcher' => ['/pricing', '/vi/pricing', 'vi', true, null], - 'an English-only release post' => ['/blog/tablepro-0-77', '/vi/blog', 'vi', false, null], -]); diff --git a/tests/Feature/Landing/IosPageTest.php b/tests/Feature/Landing/IosPageTest.php deleted file mode 100644 index 77c94d15..00000000 --- a/tests/Feature/Landing/IosPageTest.php +++ /dev/null @@ -1,298 +0,0 @@ - Http::response([], 500), 'github.com/*' => Http::response([], 500), 'apps.apple.com/*' => Http::response([], 500), 'itunes.apple.com/*' => Http::response([], 500)]); -}); - -/** - * @return array - */ -function iosPageContent(string $locale): array -{ - return json_decode((string) file_get_contents(resource_path("data/content/{$locale}/ios.json")), true, 512, JSON_THROW_ON_ERROR); -} - -/** - * @return array - */ -function iosPagePlatform(): array -{ - $platforms = json_decode((string) file_get_contents(resource_path('data/platforms.json')), true, 512, JSON_THROW_ON_ERROR)['platforms']; - - return collect($platforms)->firstWhere('id', 'ios'); -} - -function iosSsrHtml(string $path): string -{ - static $html = []; - - requireSsr(); - - return $html[$path] ??= ssrHtml($path); -} - -function iosPageText(string $locale): string -{ - return implode("\n", array_filter(Arr::dot(iosPageContent($locale)), 'is_string')); -} - -it('renders in both languages from its content and the data files, asking GitHub for nothing', function (string $path, string $locale, string $title, string $published): void { - $ios = iosPagePlatform(); - $engines = collect(json_decode((string) file_get_contents(resource_path('data/engines.json')), true))->keyBy('id'); - // The iPad capture under the header is preloaded exactly while it is supplied. - $assets = app(AssetManifest::class); - $lcp = $assets->lcpDescriptor('ipad-table-browse', $locale, priority: true, sizes: IosController::IPAD_SIZES); - - expect($lcp === null)->toBe(! $assets->isSupplied('ipad-table-browse')); - - get($path) - ->assertOk() - ->assertInertia(fn(AssertableInertia $page) => $page - ->component('Ios') - ->where('locale', $locale) - ->where('content.hero.title', $title) - ->where('ios.version', $ios['release']['version']) - ->where('ios.publishedAt', $ios['release']['publishedAt']) - ->where('ios.publishedAtFormatted', $published) - ->where('ios.appStoreUrl', $ios['destinations'][0]['url']) - ->where('ios.requirements.systems', ['iOS', 'iPadOS']) - ->where('ios.free', true) - ->where('ios.inAppPurchases', false) - ->where('engines.picker', fn($picker): bool => collect($picker)->pluck('name')->all() === collect($ios['iosEngines'])->map(fn(string $id): string => $engines[$id]['name'])->all() - // An engine name links only to a database page the site serves. - && collect($picker)->every(fn(array $engine): bool => $engine['href'] === null || in_array(ltrim((string) strtok($engine['href'], '#'), '/'), DatabaseSlugs::ALL, true))) - ->where('engines.syncedOnly', fn($synced): bool => collect($synced)->pluck('id')->all() === ['redshift']) - ->where('safeModeLevels', ['Off', 'Confirm Writes', 'Read-Only']) - ->where('limits.history', fn(int $value): bool => $value > 0) - ->where('limits.results', fn(int $value): bool => $value > 0) - ->has('links.license') - ->has('organizationProfiles') - ->where('lcpAsset', $lcp)); - - // The SSR render request is the only one the page may cause. - Http::assertNotSent(fn(Request $request): bool => str_contains($request->url(), 'github')); -})->with([ - 'English' => ['/ios', 'en', 'TablePro for iPhone and iPad', 'September 22, 2026'], - 'Vietnamese' => ['/vi/ios', 'vi', 'TablePro cho iPhone và iPad', '22 tháng 9 năm 2026'], -]); - -it('indexes every complete translation', function (): void { - $entry = app(PageRegistry::class)->find('landing.ios', []); - - expect($entry)->not->toBeNull(); - expect($entry->renderLocales)->toBe(Locales::codes()); - expect($entry->hreflangCluster())->toBe(Locales::codes()); -}); - -it('never lets a slug list swallow /ios', function (): void { - // `/{slug}` is the database catch-all: `ios` in a slug list would render a database page at /ios. - expect(DatabaseSlugs::ALL)->not->toContain('ios'); - expect(CompareSlugs::ALL)->not->toContain('ios'); - expect(FeatureSlugs::ALL)->not->toContain('ios'); - - expect(app('router')->getRoutes()->match(request()->create('/ios'))->getName())->toBe('landing.ios'); - expect(app('router')->getRoutes()->match(request()->create('/vi/ios'))->getName())->toBe('vi.landing.ios'); -}); - -it('points to the App Store, never to the TestFlight beta it replaced', function (): void { - $sources = [ - 'resources/js/pages/Ios.tsx' => (string) file_get_contents(resource_path('js/pages/Ios.tsx')), - 'content/en/ios.json' => iosPageText('en'), - 'content/vi/ios.json' => iosPageText('vi'), - ]; - - foreach ($sources as $file => $text) { - foreach (['TestFlight', '/beta/signup', 'Join Beta', 'beta'] as $needle) { - Assert::assertStringNotContainsStringIgnoringCase($needle, $text, "{$file} still mentions \"{$needle}\"; the app is on the App Store"); - } - } - - // No country segment: Apple sends each reader to their own storefront. - expect(iosPagePlatform()['destinations'][0]['url'])->toMatch('#^https://apps\.apple\.com/app/[a-z0-9-]+/id\d+$#'); -}); - -it('ships Apple\'s badge artwork unmodified, in both themes and both languages', function (string $suffix, string $language): void { - // Apple's title names the language and colour; a changed viewBox means the trademarked badge was redrawn or cropped. - foreach (['light' => 'blk', 'dark' => 'wht'] as $theme => $colour) { - $svg = (string) file_get_contents(public_path("images/app-store-{$theme}{$suffix}.svg")); - - expect($svg)->toContain('viewBox="0 0 119.66407 40"') - ->toMatch("#Download_on_the_App_Store_Badge_{$language}_RGB_{$colour}_[^<]*#"); - } -})->with([ - 'English' => ['', 'US-UK'], - 'Vietnamese' => ['-vi', 'VN'], -]); - -it('shows the App Store badge in the page\'s language, labelled with its visible text', function (string $path, string $suffix, string $label): void { - $html = iosSsrHtml($path); - - preg_match_all('#]*>\s*([^]*>\s*([^toBeGreaterThanOrEqual(2) - ->toBe(substr_count($html, 'with([ - 'English' => ['/ios', '', 'Download on the App Store'], - 'Vietnamese' => ['/vi/ios', '-vi', 'Tải về trên App Store'], -])->group('ssr'); - -it('names the engines and never counts them', function (string $locale): void { - $text = iosPageText($locale); - - // Spelled-out counts only: NoTypedCountsTest holds the numerals. - Assert::assertStringNotContainsStringIgnoringCase('seven', $text); - Assert::assertStringNotContainsStringIgnoringCase('ten databases', $text); - - // The picker's names are not typed into the copy as a list either: the page renders them from data. - expect((string) file_get_contents(resource_path('js/pages/Ios.tsx')))->toContain('engines.picker.map('); -})->with(['en', 'vi']); - -it('describes App Store 1.0, not the Mac app or the unreleased source', function (string $locale): void { - $text = iosPageText($locale); - $source = (string) file_get_contents(resource_path('js/pages/Ios.tsx')); - - // Each is true of the Mac or of source merged after build 22, and false of App Store 1.0. - foreach (['six', 'Silent', 'Alert (Full)', 'side by side', 'side-by-side', 'beside the browser', 'visionOS', 'Vision Pro', 'Optic ID', 'universal app', 'nothing connects', 'iPhone Duo'] as $needle) { - Assert::assertStringNotContainsStringIgnoringCase($needle, $text, "content/{$locale}/ios.json says \"{$needle}\""); - Assert::assertStringNotContainsStringIgnoringCase($needle, $source, "Ios.tsx says \"{$needle}\""); - } -})->with(['en', 'vi']); - -it('states the limits a phone user would otherwise discover', function (): void { - $en = iosPageContent('en'); - $limits = implode("\n", $en['limits']['items']); - - // Jump hosts are refused or skipped on the phone, and Redis keys do not open in 1.0. - expect($limits)->toContain('jump hosts')->toContain('Redis keys')->toContain('AI assistant'); - expect($en['databases']['redis'])->toContain('Query tab'); - - expect(implode("\n", $en['browse']['paragraphs']))->toContain('primary key')->toContain('Read-Only'); - - expect(implode("\n", $en['safeMode']['paragraphs']))->toContain('{levels}'); - - expect(implode("\n", $en['mac']['paragraphs']))->toContain('Pro or Team')->toContain('free here'); - - expect($en['privacy']['paragraphs'][0])->toStartWith('Usage reporting is off by default.') - ->toContain('Enable Share Usage Data in Settings > Privacy') - ->toContain('No hostnames, usernames, passwords, queries or rows.'); - - expect(iosPageContent('vi')['limits']['items'])->toHaveCount(count($en['limits']['items'])); -}); - -it('keeps the notes about the released version in one block, out of the feature copy', function (): void { - $en = iosPageContent('en'); - $issues = implode("\n", $en['knownIssues']['items']); - - // Each is true of App Store 1.0 (build 22): the row editor, the sync round trip, Redis TLS and the Shortcuts CSV parser. - expect($issues)->toContain('long text or binary value')->toContain('SSH jump hosts')->toContain('not the host name')->toContain('LF line endings'); - expect($en['knownIssues']['title'])->toContain('{version}'); - - // A feature paragraph says what the app does; "in this version" belongs under the version's heading. - $features = Arr::except($en, ['knownIssues', 'seo', 'og']); - - Assert::assertStringNotContainsStringIgnoringCase('this version', implode("\n", array_filter(Arr::dot($features), 'is_string'))); - - foreach (Locales::codes() as $locale) { - $translated = iosPageContent($locale)['knownIssues']; - - expect($translated['items'])->toHaveCount(count($en['knownIssues']['items'])); - expect($translated['title'])->toContain('{version}'); - } -}); - -it('server-renders the known issues under the App Store version, after the Mac-only list', function (): void { - $html = iosSsrHtml('/ios'); - $version = iosPagePlatform()['release']['version']; - $limits = strpos($html, 'id="limits"'); - $issues = strpos($html, 'id="known-issues"'); - $privacy = strpos($html, 'id="privacy"'); - - expect($limits)->not->toBeFalse(); - expect($issues)->not->toBeFalse('/ios has no #known-issues'); - expect($privacy)->not->toBeFalse(); - expect($issues)->toBeGreaterThan($limits)->toBeLessThan($privacy); - expect($html)->toMatch('#

]*>Known issues in version ' . preg_quote($version, '#') . '

#'); -})->group('ssr'); - -it('places every iPhone and iPad slot and only manifest ids', function (): void { - $source = (string) file_get_contents(resource_path('js/pages/Ios.tsx')); - $assets = json_decode((string) file_get_contents(resource_path('data/assets.json')), true)['assets']; - - preg_match_all('/toHaveKey($id); - } - - // A phone crop renders through the slot of the entry that names it. - $placed = $matches[1]; - - foreach ($matches[1] as $id) { - if (is_string($assets[$id]['mobile'] ?? null)) { - $placed[] = $assets[$id]['mobile']; - } - } - - foreach ($assets as $id => $entry) { - $onThisPage = collect($entry['usedOn'])->contains(fn(array $use): bool => $use['path'] === '/ios'); - - if ($entry['slot'] && $onThisPage) { - expect($placed)->toContain($id); - } - } -}); - -it('emits one iPhone and iPad application node, with no rating and no FAQ markup', function (): void { - $html = iosSsrHtml('/ios'); - - expect(substr_count($html, '"@type":"MobileApplication"'))->toBe(1); - expect($html)->toContain('"@id":"https://localhost/#ios-app"'); - expect($html)->not->toContain('"FAQPage"'); - - foreach (['aggregateRating', 'ratingValue'] as $needle) { - Assert::assertStringNotContainsString($needle, $html, '/ios published a rating nobody gave'); - } -})->group('ssr'); - -it('server-renders the engine names from the data, in picker order', function (): void { - $html = iosSsrHtml('/ios'); - $engines = collect(json_decode((string) file_get_contents(resource_path('data/engines.json')), true))->keyBy('id'); - - $start = strpos($html, 'id="databases"'); - $end = strpos($html, 'id="browse"', (int) $start); - - expect($start)->not->toBeFalse(); - - $section = substr($html, (int) $start, (int) $end - (int) $start); - - preg_match_all('#
  • (?:]*>)?([^<]+)#', $section, $cells); - - expect($cells[1])->toBe(collect(iosPagePlatform()['iosEngines'])->map(fn(string $id): string => $engines[$id]['name'])->all()); -})->group('ssr'); diff --git a/tests/Feature/Landing/LandingSeoTest.php b/tests/Feature/Landing/LandingSeoTest.php deleted file mode 100644 index 6971d0c2..00000000 --- a/tests/Feature/Landing/LandingSeoTest.php +++ /dev/null @@ -1,228 +0,0 @@ - Http::response([], 200)]); -}); - -/** - * @return list - */ -function landingSeoCreativeWorks(): array -{ - return ['WebPage', 'CollectionPage', 'ItemPage', 'AboutPage', 'BlogPosting', 'Article', 'SoftwareApplication', 'MobileApplication', 'WebApplication']; -} - -function getOnWebDomainSeo(string $path): TestResponse -{ - return test()->get('http://' . config('app.web_domain') . $path); -} - -function landingSeoDocument(string $path, int $status = 200): Dom\HTMLDocument -{ - requireSsr(); - - $response = getOnWebDomainSeo($path); - $response->assertStatus($status); - - return Dom\HTMLDocument::createFromString((string) $response->getContent(), LIBXML_NOERROR); -} - -/** - * @return list - */ -function landingSeoValues(Dom\HTMLDocument $document, string $selector, string $attribute): array -{ - return array_map( - static fn(Dom\Element $element): string => (string) $element->getAttribute($attribute), - iterator_to_array($document->querySelectorAll($selector)), - ); -} - -/** - * @return list> - */ -function landingSeoJsonLdNodes(Dom\HTMLDocument $document): array -{ - $nodes = []; - - foreach ($document->querySelectorAll('script[type="application/ld+json"]') as $script) { - $data = json_decode((string) $script->textContent, true, 512, JSON_THROW_ON_ERROR); - - foreach (array_is_list($data) ? $data : [$data] as $block) { - foreach ($block['@graph'] ?? [$block] as $node) { - $nodes[] = $node; - } - } - } - - return $nodes; -} - -it('keeps the health check out of search results, and marks no page that way', function (): void { - $this->get('/up')->assertOk()->assertHeader('X-Robots-Tag', 'noindex'); - $this->get('/pricing')->assertOk()->assertHeaderMissing('X-Robots-Tag'); -}); - -it('serves robots.txt that allows everything and lists both sitemaps', function (): void { - // robots.txt is not access control: private pages carry their own noindex. - $response = get(route('web.robots')); - - $response->assertOk() - ->assertHeader('Content-Type', 'text/plain; charset=UTF-8') - ->assertSee('Allow: /') - ->assertSee('Sitemap: https://tablepro.app/sitemap.xml') - ->assertSee('Sitemap: https://docs.tablepro.app/sitemap.xml'); - - expect($response->getContent())->not->toContain('Disallow'); -}); - -it('renders the registry head on every page family, in each language', function (string $path, string $locale): void { - $document = landingSeoDocument($path); - $entry = app(SeoContext::class)->entryFor(seoMatchedRequest($path, $locale)); - - Assert::assertNotNull($entry, "{$path} is not a registry page"); - - // app.blade.php once hard-coded a second robots meta that contradicted the head's. - $canonical = $entry->isIndexable($locale) ? [$entry->url($locale)] : []; - - expect($document->documentElement->getAttribute('lang'))->toBe($locale); - expect(landingSeoValues($document, 'meta[name="robots"]', 'content'))->toBe([$entry->robots($locale)]); - expect(landingSeoValues($document, 'link[rel="canonical"]', 'href'))->toBe($canonical); - expect(landingSeoValues($document, 'meta[property="og:url"]', 'content'))->toBe($canonical); - expect(landingSeoValues($document, 'meta[property="og:locale"]', 'content'))->toBe([Locales::definition($locale)['og']]); - - $expected = []; - $ogAlternates = []; - - if (in_array($locale, $entry->hreflangCluster(), true)) { - foreach ($entry->hreflangCluster() as $code) { - $expected[Locales::definition($code)['hreflang']] = $entry->url($code); - - if ($code !== $locale) { - $ogAlternates[] = Locales::definition($code)['og']; - } - } - - $expected['x-default'] = $entry->url(Locales::default()); - } - - expect(landingSeoValues($document, 'meta[property="og:locale:alternate"]', 'content'))->toBe($ogAlternates); - - $hreflangs = []; - - foreach ($document->querySelectorAll('link[rel="alternate"][hreflang]') as $link) { - $hreflangs[(string) $link->getAttribute('hreflang')] = (string) $link->getAttribute('href'); - } - - ksort($expected); - ksort($hreflangs); - - expect($hreflangs)->toBe($expected); - expect(count($document->querySelectorAll('link[rel="alternate"][hreflang]')))->toBe(count($expected), "{$path} repeats an alternate"); - - $images = landingSeoValues($document, 'meta[property="og:image"]', 'content'); - - expect($images)->toHaveCount(1, "{$path} shares no card, or more than one"); - expect(seoPathOf($images[0]))->toBe(seoPathOf((string) (app(OgImages::class)->for($entry, $locale)['url'] ?? ''))); - expect(landingSeoValues($document, 'meta[name="twitter:image"]', 'content'))->toBe($images); - - $file = base_path('public' . seoPathOf($images[0])); - - expect(File::isFile($file))->toBeTrue("{$path} shares {$images[0]}, which is not in public/"); - - $size = (array) getimagesize($file); - - expect(landingSeoValues($document, 'meta[property="og:image:width"]', 'content'))->toBe([(string) $size[0]]); - expect(landingSeoValues($document, 'meta[property="og:image:height"]', 'content'))->toBe([(string) $size[1]]); - - $alt = (string) app(OgImages::class)->for($entry, $locale)['alt']; - - expect($alt)->not->toBe('', "{$path} does not say what its card shows"); - expect(landingSeoValues($document, 'meta[property="og:image:alt"]', 'content'))->toBe([$alt]); - expect(landingSeoValues($document, 'meta[name="twitter:image:alt"]', 'content'))->toBe([$alt]); - - $works = 0; - - foreach (landingSeoJsonLdNodes($document) as $node) { - $type = $node['@type'] ?? null; - - expect($type)->not->toBeIn(['FAQPage', 'HowTo', 'Review', 'AggregateRating']); - expect(array_intersect(array_keys($node), ['aggregateRating', 'review', 'reviewRating']))->toBe([], "{$path}: a {$type} node carries a rating"); - - if ($type === 'Organization') { - expect((string) ($node['@id'] ?? ''))->toEndWith('/#organization'); - } - - if ($type === 'WebSite') { - expect($node['inLanguage'] ?? null)->toBe(array_map(fn(string $code): string => Locales::definition($code)['hreflang'], Locales::codes())); - - continue; - } - - if (in_array($type, landingSeoCreativeWorks(), true)) { - expect($node['inLanguage'] ?? null)->toBe(Locales::definition($locale)['hreflang'], "{$path}: the {$type} node does not state the page's language"); - $works++; - } - } - - expect($works)->toBeGreaterThan(0, "{$path} describes nothing in its own language"); -})->with([ - 'home' => ['/', 'en'], - 'download' => ['/download', 'en'], - 'download, in Vietnamese' => ['/vi/download', 'vi'], - 'pricing' => ['/pricing', 'en'], - 'iPhone and iPad' => ['/ios', 'en'], - 'FAQ' => ['/faq', 'en'], - 'about' => ['/about', 'en'], - 'about, in Japanese' => ['/ja/about', 'ja'], - 'features hub' => ['/features', 'en'], - 'a feature' => ['/features/querying', 'en'], - 'databases hub' => ['/databases', 'en'], - 'an engine' => ['/mysql-client', 'en'], - 'compare hub' => ['/compare', 'en'], - 'a comparison' => ['/compare/dbeaver', 'en'], - 'the blog' => ['/blog', 'en'], - 'the blog in German, rendered but not indexed' => ['/de/blog', 'de'], - 'an English-only release post' => ['/blog/tablepro-0-77', 'en'], - 'a legal page' => ['/privacy', 'en'], - 'security' => ['/security', 'en'], -])->group('ssr'); - -it('marks an error page noindex, follow and points it at nothing', function (string $path, int $status, string $locale): void { - $document = landingSeoDocument($path, $status); - - expect($document->documentElement->getAttribute('lang'))->toBe($locale); - expect(landingSeoValues($document, 'meta[name="robots"]', 'content'))->toBe(['noindex, follow']); - expect(landingSeoValues($document, 'meta[property="og:locale"]', 'content'))->toBe([Locales::definition($locale)['og']]); - - foreach (['link[rel="canonical"]', 'link[rel="alternate"][hreflang]', 'meta[property="og:url"]', 'meta[property="og:image"]', 'meta[property="og:locale:alternate"]', 'script[type="application/ld+json"]'] as $selector) { - expect(count($document->querySelectorAll($selector)))->toBe(0, "{$path} carries {$selector}"); - } -})->with([ - 'a 404' => ['/no-such-page', 404, 'en'], - 'a 404, in Vietnamese' => ['/vi/no-such-page', 404, 'vi'], - 'an English-only post asked for in Vietnamese' => ['/vi/blog/tablepro-0-77', 404, 'vi'], - 'a removed comparison' => ['/compare/azimutt', 410, 'en'], -])->group('ssr'); - -it('dates a post in its Open Graph tags, and no other page', function (): void { - $published = fn(string $path): array => landingSeoValues(landingSeoDocument($path), 'meta[property="article:published_time"]', 'content'); - - expect($published('/blog/tablepro-0-77'))->toBe(['2026-10-02']) - ->and($published('/blog'))->toBe([]) - ->and($published('/pricing'))->toBe([]); -})->group('ssr'); diff --git a/tests/Feature/Landing/LandingTest.php b/tests/Feature/Landing/LandingTest.php deleted file mode 100644 index 28f2bdf2..00000000 --- a/tests/Feature/Landing/LandingTest.php +++ /dev/null @@ -1,109 +0,0 @@ - Http::response([], 200)]); -}); - -/** - * @return array}> - */ -function landingFamilies(): array -{ - return [ - 'landing.home' => ['Home', ['content.hero.title', 'engines.0.path', 'apiOrigin', 'iosEngines']], - 'landing.download' => ['Download', ['content', 'mac', 'release', 'unreleased']], - 'landing.ios' => ['Ios', ['content', 'ios', 'engines']], - 'landing.pricing' => ['Pricing', ['content', 'apiOrigin', 'paidFeatures']], - 'landing.teams' => ['Teams', ['content.header.title', 'content.seats.items', 'organizationProfiles']], - 'landing.faq' => ['Faq', ['content', 'facts', 'platforms']], - 'landing.about' => ['About', ['content', 'publisher.name', 'repositoryCreated', 'links']], - 'landing.security' => ['Security', ['content.sections.0.items', 'facts.macSafeModeLevels', 'links.email']], - 'landing.sponsors' => ['Sponsors', ['content.header.title', 'content.tiers.benefit.name', 'organizationProfiles']], - 'landing.black-friday' => ['BlackFriday', ['content.hero.title', 'paidFeatures', 'organizationProfiles', 'campaign.phase']], - 'landing.privacy' => ['Privacy', ['document.title', 'document.html', 'document.toc']], - 'landing.terms' => ['Terms', ['document.title', 'document.html', 'document.toc']], - 'landing.refundPolicy' => ['RefundPolicy', ['document.title', 'document.html', 'document.toc']], - 'landing.brand' => ['Brand', ['document.title', 'document.html', 'document.toc']], - 'landing.blog.index' => ['Blog/Index', ['content', 'posts.0']], - 'landing.blog.show' => ['Blog/Post', ['post', 'related']], - 'landing.features.index' => ['Features/Index', ['content', 'pages.0', 'facts']], - 'landing.features.show' => ['Features/Show', ['content', 'slug', 'facts', 'labels']], - 'landing.databases.index' => ['Databases/Index', ['content', 'engines.0', 'platforms']], - 'landing.databaseClient' => ['Databases/Show', ['content', 'engine', 'slug', 'platforms']], - 'landing.compare.index' => ['Compare/Index', ['content', 'products.0', 'tablepro']], - 'landing.compare' => ['Compare/Show', ['content', 'product', 'slug', 'tablepro']], - 'landing.integrations.index' => ['Integrations/Index', ['content', 'integrations', 'filters']], - 'landing.integrations.show' => ['Integrations/Show', ['content.show', 'integration', 'dates']], - ]; -} - -it('knows every page family the registry lists', function (): void { - $routes = array_values(array_unique(array_map( - fn($entry): string => $entry->route, - app(PageRegistry::class)->all(), - ))); - - sort($routes); - $known = array_keys(landingFamilies()); - sort($known); - - expect($routes)->toBe($known, 'A page family was added or retired; update landingFamilies()'); - - foreach (landingFamilies() as [$component]) { - Assert::assertFileExists(resource_path("js/pages/{$component}.tsx"), "{$component} is not a page component"); - } -}); - -it('renders every page of a family with its component and the props it needs', function (): void { - $crawl = seoCrawlProps(); - $families = landingFamilies(); - $rendered = []; - - foreach (app(PageRegistry::class)->all() as $entry) { - [$component, $props] = $families[$entry->route] ?? [null, []]; - - foreach ($entry->renderLocales as $locale) { - $path = $entry->url($locale, false); - $page = $crawl[$path]; - - Assert::assertSame($component, $page['component'], "{$path} renders the wrong component"); - - foreach ($props as $prop) { - Assert::assertTrue(Arr::has($page['props'], $prop), "{$path} has no {$prop} prop"); - } - - $rendered[$entry->route][$locale] = true; - } - } - - // Every family renders in English, and every family but the release posts, the brand guidelines and integration pages in Vietnamese too. - foreach (array_keys($families) as $route) { - expect($rendered[$route] ?? [])->toHaveKey('en', "{$route} renders nowhere in English"); - - if (! in_array($route, ['landing.blog.show', 'landing.brand', 'landing.integrations.show'], true)) { - expect($rendered[$route] ?? [])->toHaveKey('vi', "{$route} renders nowhere in Vietnamese"); - } - } -}); - -it('renders and indexes the Vietnamese blog listing', function (): void { - get('/vi/blog') - ->assertOk() - ->assertInertia(fn(AssertableInertia $page) => $page - ->component('Blog/Index') - ->where('locale', 'vi') - ->where('seo.robots', 'index, follow') - ->has('posts', count(glob(resource_path('blog/*.md')) ?: []))); -}); diff --git a/tests/Feature/Landing/LanguageBarTest.php b/tests/Feature/Landing/LanguageBarTest.php deleted file mode 100644 index 64649568..00000000 --- a/tests/Feature/Landing/LanguageBarTest.php +++ /dev/null @@ -1,119 +0,0 @@ -endsAt ?? now()); -}); - -const SUGGESTION_MARKER = '/* language-suggestion:start */'; - -/** @return array */ -function suggestionCopy(string $locale): array -{ - $source = (string) file_get_contents(dirname(__DIR__, 3) . "/resources/js/i18n/messages/{$locale}/controls.ts"); - $block = substr($source, (int) strpos($source, 'suggest')); - $block = substr($block, 0, (int) strpos($block, '}')); - - // English and Vietnamese are written `key: '…',`, the other catalogs `"key": "…"`. - preg_match_all('/^\s+"?(\w+)"?: ([\'"])((?:(?!\2)[^\\\\]|\\\\.)*)\2,?$/m', $block, $matches, PREG_SET_ORDER); - - $strings = []; - - foreach ($matches as $match) { - $strings[$match[1]] = stripslashes($match[3]); - } - - return $strings; -} - -it('offers the equivalent pages only, never the current one or a fallback', function (): void { - get('/')->assertInertia(fn(AssertableInertia $page) => $page - ->where('localization.suggestable', array_values(array_diff(Locales::codes(), ['en'])))); - - get('/vi')->assertInertia(fn(AssertableInertia $page) => $page - ->where('localization.suggestable', fn($locales): bool => in_array('en', collect($locales)->all(), true) && ! in_array('vi', collect($locales)->all(), true))); - - // A release post is English only: every other option is the blog list, a fallback. - get('/blog/tablepro-0-67')->assertOk()->assertInertia(fn(AssertableInertia $page) => $page->where('localization.suggestable', [])); -}); - -it('decides before first paint wherever the page exists in another language, the pricing page included', function (string $path): void { - $html = get($path)->assertOk()->getContent(); - - expect($html)->toContain(SUGGESTION_MARKER) - ->toContain("localStorage.getItem('tablepro:language')"); -})->with(['/', '/vi/download', '/pricing']); - -it('ships no script where the page has no other language, nor on an error page', function (string $path, int $status): void { - $html = get($path)->assertStatus($status)->getContent(); - - Assert::assertStringNotContainsString(SUGGESTION_MARKER, $html); - Assert::assertStringNotContainsString('tablepro:language', $html); -})->with([ - 'an english-only release post' => ['/blog/tablepro-0-67', 200], - 'a missing page' => ['/no-such-page', 404], -]); - -it('gives the head script the page\'s half only: codes and aliases, never a country or a time zone', function (): void { - $config = LanguageDetection::headConfig('en', ['vi']); - - expect(array_keys($config))->toBe(['page', 'suggestable', 'supported', 'tags']) - ->and($config['supported'])->toBe(array_combine(array_map(strtolower(...), Locales::codes()), Locales::codes())) - ->and($config['tags'])->toBe(json_decode((string) file_get_contents(resource_path('data/language-detection.json')), true)['tags']); -}); - -it('serves every reader the same page, whatever their browser or country says', function (string $path): void { - $plain = get($path)->assertOk(); - $guess = $this->withHeaders(['Accept-Language' => 'vi-VN,vi;q=0.9', 'CF-IPCountry' => 'VN'])->get($path)->assertOk(); - - expect($guess->getContent())->toBe($plain->getContent()) - ->and($guess->headers->getCookies())->toBe([]) - ->and((string) $guess->headers->get('Vary'))->not->toContain('Accept-Language'); -})->with(['/', '/pricing']); - -it('renders an empty slot for the bar after the license banner and above the header', function (): void { - config(['banner.enabled' => true]); - - $html = ssrHtml('/'); - $license = strpos($html, 'class="support-banner'); - $bar = strpos($html, 'class="language-bar'); - $header = strpos($html, ']*class="language-bar[^"]*"[^>]*>(.*?)<\/div>/s', $html, $element); - - expect(trim($element[1] ?? 'missing'))->toBe('', 'The server cannot know the reader\'s language, so the bar is empty until the browser decides'); -})->group('ssr'); - -it('shares the license banner\'s slot and hides it while it shows', function (): void { - $css = (string) file_get_contents(resource_path('css/app.css')); - - expect($css)->toContain("html.has-language-bar {\n --banner-h: 2.5rem;\n}") - ->toContain("html.has-language-bar .language-bar {\n display: block;") - ->toContain("html.has-language-bar .support-banner {\n display: none;\n}"); -}); - -it('speaks the language it offers, on one line of a phone', function (string $locale): void { - $copy = suggestionCopy($locale); - - expect($copy)->toHaveKeys(['label', 'action', 'dismiss']); - - // A 320px screen holds 34 columns beside the close button (TopBannerTest); a CJK character is two, and the arrow two more. - Assert::assertLessThanOrEqual(32, mb_strwidth($copy['action']), "{$locale}: the link will not fit one line on a 320px screen"); - - if ($locale !== 'en') { - Assert::assertNotSame(suggestionCopy('en')['action'], $copy['action'], "{$locale}: the link is still in English"); - } -})->with(fn(): array => array_keys(json_decode((string) file_get_contents(dirname(__DIR__, 3) . '/resources/data/locales.json'), true)['supported'])); diff --git a/tests/Feature/Landing/LegalPagesTest.php b/tests/Feature/Landing/LegalPagesTest.php deleted file mode 100644 index bd619088..00000000 --- a/tests/Feature/Landing/LegalPagesTest.php +++ /dev/null @@ -1,646 +0,0 @@ - 'first query', - 'vi' => 'query đầu tiên', - 'es' => 'primera consulta', - 'de' => 'ersten Abfrage', - 'fr' => 'première requête', - 'ja' => '初めてクエリを実行', - 'pt-BR' => 'primeira consulta', - 'zh-Hans' => '首次运行查询', - 'ko' => '첫 쿼리', - 'zh-Hant' => '首次執行查詢', - 'it' => 'prima query', - 'id' => 'kueri pertama', -]; - -// The phrase each language uses for a monthly or yearly renewal, refundable on its own. -const LEGAL_RENEWAL_REFUND = [ - 'en' => 'each monthly or yearly renewal', - 'vi' => 'mỗi lần gia hạn theo tháng hoặc theo năm', - 'es' => 'cada renovación mensual o anual', - 'de' => 'jede monatliche oder jährliche Verlängerung', - 'fr' => 'chaque renouvellement mensuel ou annuel', - 'ja' => '月払い・年払いの各更新', - 'pt-BR' => 'cada renovação mensal ou anual', - 'zh-Hans' => '月付或年付方案的每次续订', - 'ko' => '월간 또는 연간 플랜의 각 갱신', - 'zh-Hant' => '月付或年付方案的每次續訂', - 'it' => 'ogni rinnovo mensile o annuale', - 'id' => 'setiap perpanjangan bulanan atau tahunan', -]; - -// The shorter marketing sentence still promises a new window for each charge. -const MARKETING_RENEWAL_REFUND = [ - 'en' => ['Purchases and subscription renewals', 'within {refundDays} days of each charge'], - 'vi' => ['Mỗi lần mua hoặc gia hạn subscription', 'trong {refundDays} ngày từ lần thu phí đó'], - 'es' => ['Las compras y renovaciones de suscripciones', '{refundDays} días siguientes a cada cobro'], - 'de' => ['Käufe und Aboverlängerungen', '{refundDays} Tagen nach jeder Abbuchung'], - 'fr' => ['Les achats et renouvellements d’abonnement', '{refundDays} jours suivant chaque paiement'], - 'ja' => ['購入やサブスクリプションの更新', '各請求から {refundDays} 日以内'], - 'pt-BR' => ['Compras e renovações de assinatura', '{refundDays} dias após cada cobrança'], - 'zh-Hans' => ['购买和订阅续费', '每次扣款后 {refundDays} 天内退款'], - 'ko' => ['구매와 구독 갱신', '각 결제 후 {refundDays}일 이내'], - 'zh-Hant' => ['購買和訂閱續費', '每次扣款後 {refundDays} 天內退款'], - 'it' => ['Gli acquisti e i rinnovi degli abbonamenti', '{refundDays} giorni da ogni addebito'], - 'id' => ['Pembelian dan perpanjangan langganan', '{refundDays} hari setelah setiap tagihan'], -]; - -/** - * @return list - */ -function legalLocales(): array -{ - // Read from disk: a dataset is built before the application boots. - return array_keys(json_decode((string) file_get_contents(dirname(__DIR__, 3) . '/resources/data/locales.json'), true)['supported']); -} - -/** - * @return list - */ -function legalDocumentsIn(string $locale): array -{ - return $locale === 'en' ? LegalDocuments::DOCUMENTS : array_values(array_diff(LegalDocuments::DOCUMENTS, LegalDocuments::ENGLISH_ONLY)); -} - -function legalSource(string $document, string $locale): string -{ - return (string) file_get_contents(dirname(__DIR__, 3) . "/resources/data/legal/{$locale}/{$document}.md"); -} - -function legalSection(string $document, string $locale, string $id): string -{ - $body = YamlFrontMatter::parse(legalSource($document, $locale))->body(); - $anchor = '[^\n]*\{#' . preg_quote($id, '/') . '\}\n'; - - if (preg_match('/^(#{2,6}) ' . $anchor . '/m', $body, $heading) !== 1) { - return ''; - } - - // A section runs to the next heading of its own level or above. - preg_match('/^' . $heading[1] . ' ' . $anchor . '(.*?)(?=^#{2,' . strlen($heading[1]) . '} |\z)/ms', $body, $match); - - return $match[1] ?? ''; -} - -/** - * @return list - */ -function legalShape(string $document, string $locale): array -{ - return array_map(function (string $line): string { - preg_match_all('/\{[a-zA-Z][a-zA-Z0-9]*\}/', $line, $found); - $slots = array_unique($found[0]); - sort($slots); - - $kind = match (true) { - trim($line) === '' => '', - preg_match('/^(#{2,6}) .*(\{#[a-z0-9-]+\})$/', $line, $heading) === 1 => "{$heading[1]} {$heading[2]}", - str_starts_with($line, '- ') => '-', - default => 'p', - }; - - return trim($kind . ' ' . implode(' ', $slots)); - }, explode("\n", YamlFrontMatter::parse(legalSource($document, $locale))->body())); -} - -it('renders each document in both languages under its own component', function (string $path, string $component, string $title): void { - get($path) - ->assertOk() - ->assertInertia(fn(AssertableInertia $page) => $page - ->component($component) - ->where('document.title', $title) - ->where('document.updatedAt', fn(string $date): bool => preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1 && $date <= now()->toDateString()) - ->where('document.html', fn(string $html): bool => str_contains($html, '

    toBe(legalShape($document, 'en')); - expect(YamlFrontMatter::parse(legalSource($document, $locale))->matter('updatedAt')) - ->toBe(YamlFrontMatter::parse(legalSource($document, 'en'))->matter('updatedAt')); - } -})->with(array_values(array_diff(legalLocales(), ['en']))); - -it('takes every number and address from the data files, the same ones in every language', function (string $locale): void { - $documents = app(LegalDocuments::class); - - foreach (legalDocumentsIn($locale) as $document) { - expect($documents->tokenNames($document, $locale))->toBe($documents->tokenNames($document, 'en')); - expect(array_diff($documents->tokenNames($document, $locale), array_keys($documents->tokens($locale))))->toBe([]); - - $source = legalSource($document, $locale); - - // The support address, the refund window and the licence URL are slots, never typed. - Assert::assertStringNotContainsString('hello@tablepro.app', $source); - Assert::assertStringNotContainsString('github.com', $source); - Assert::assertStringNotContainsString('polar.sh', $source); - } -})->with(legalLocales()); - -it('closes every bold span on the rendered page', function (string $locale): void { - // CommonMark leaves ** in the text when the mark sits between punctuation and a letter, as in 。**后. - foreach (legalDocumentsIn($locale) as $document) { - expect(app(LegalDocuments::class)->render($document, $locale)['html'])->not->toContain('**'); - } -})->with(legalLocales()); - -it('keeps the cookie contract the consent bar relies on', function (string $locale, string $basis, string $settings): void { - $privacy = legalSource('privacy', $locale); - $cookies = legalSection('privacy', $locale, 'cookies'); - - foreach (['_ga', 'tablepro:analytics-consent', 'tablepro:attribution', 'crisp-client/', '__Host-tp_session', 'theme', 'sign-in-return-to'] as $name) { - expect($cookies)->toContain($name); - } - - // tablepro-cloud's portal sets one cookie and no CSRF cookie: the Laravel portal's two are gone. - expect($cookies)->not->toContain('tablepro-session')->not->toContain('XSRF-TOKEN'); - - // The banner cannot be closed, so nothing is stored about it and the policy must not say otherwise. - expect($cookies)->not->toContain('banner'); - - expect($privacy)->toContain('{#cookies}')->toContain('Google Analytics')->toContain($settings); - expect(mb_strtolower($privacy))->toContain($basis); - expect($cookies)->toContain(LegalDocuments::COOKIE_SETTINGS_MARKER); - - // nl_dismissed_at was listed for years and set by nothing. - expect($privacy)->not->toContain('nl_dismissed_at')->not->toContain('cookie-less'); -})->with([ - 'English' => ['en', 'lawful basis', 'Cookie settings'], - 'Vietnamese' => ['vi', 'cơ sở pháp lý', 'Cài đặt cookie'], -]); - -it('describes purchase attribution as checkout metadata kept with the order, under the website and the cookies', function (string $locale, string $kept): void { - foreach (['website', 'cookies'] as $section) { - expect(legalSection('privacy', $locale, $section))->toContain('tablepro:attribution'); - } - - // tablepro-cloud stores the utm_* tags and the referrer as Polar checkout metadata and on the order (data-model.md, orders.attribution). - expect(legalSection('privacy', $locale, 'website'))->toContain($kept); - expect(legalSource('privacy', $locale))->not->toContain('discards it')->not->toContain('bỏ nó đi'); -})->with([ - 'English' => ['en', 'Our server passes the source of the visit to {merchant} as metadata of the checkout'], - 'Vietnamese' => ['vi', 'Máy chủ của chúng tôi chuyển nguồn của lượt truy cập cho {merchant}'], -]); - -it('states what the Mac app and the server actually do', function (): void { - $mac = legalSection('privacy', 'en', 'mac-app'); - - // Usage data: on by default, where to turn it off, and what the server adds. - expect($mac) - ->toContain('It is on by default, and the app does not ask before sending the first event.') - ->toContain('**Settings > General > Privacy**') - ->toContain('**Share usage data**') - ->not->toContain('Share anonymous usage data'); - - // The accounts build sends PostHog its events itself, with posthog-ios's own properties (tablepro-cloud clients.md, macOS Telemetry). - expect($mac)->toContain('`eu.i.posthog.com`') - ->toContain('if the app starts with it off, it does not start PostHog\'s library at all') - ->toContain('screen size, time zone and language, whether it is on Wi-Fi') - ->toContain('works out an approximate location from it') - ->toContain('(`/config` from `eu-assets.i.posthog.com` and `/flags` from `eu.i.posthog.com`)') - ->toContain('your first successful connection and your first query, each with its database type;') - ->toContain('Before you sign in, the events carry a random ID') - // GeoIP is on, so PostHog also sets the derived location on the identified person. - ->toContain('the app\'s language and the approximate location PostHog works out from the IP address.') - ->toContain('The app\'s usage data no longer carries a machine ID, whether a license is activated, or your update settings.') - ->not->toContain('The app no longer sends a machine ID') - ->toContain('PostHog\'s crash reporter, which the app leaves turned off') - ->toContain('`~/Library/Application Support/com.TablePro`'); - - // tablepro-cloud's /v1 heartbeat goes to PostHog with Cloudflare's country, never the key or the IP; no lookup service, no HTTP. - expect($mac)->toContain('Our server does not store the report or the IP address it came from.') - ->toContain('the country Cloudflare gives for your connection'); - expect(legalSource('privacy', 'en'))->not->toContain('ip-api.com')->not->toContain('geoplugin.net')->not->toContain('unencrypted HTTP'); - - // Devices belong to accounts, and their changes are audited with the request's IP and user agent. - expect($mac)->toContain('{#mac-account}')->toContain('macOS Keychain')->toContain('audit log'); - - // The accounts build keeps its refresh token in a Keychain item that never syncs (deviceOnlyKeychain in AccountServices+Live). - expect($mac)->toContain('kept in the macOS Keychain of that Mac only and never syncs through iCloud Keychain'); - expect(legalSection('privacy', 'en', 'icloud'))->toContain('the license key is deleted once it is exchanged for a sign-in, and the token that keeps the app signed in never syncs'); - - // The license check sends the Mac's name, not "the license key and no other data". - expect($mac)->toContain('your Mac\'s name')->not->toContain('No other data'); - expect(legalSection('privacy', 'en', 'mac-license'))->toContain('Versions before TablePro accounts contact our license server only after you enter a license key.') - ->toContain('the machine ID described under [Usage data](#mac-usage-report)') - ->not->toContain('described above'); - - // The Team Library upload carries startup commands and the tunnel command too (ConnectionExportService.buildEnvelope at v0.77.0). - expect($mac)->toContain('SQL text')->toContain('never passwords')->toContain('startup commands')->toContain('Tunnel Command'); - - // Updates come from GitHub and can be turned off; the plugin catalog cannot. - expect($mac)->toContain('raw.githubusercontent.com')->toContain('Automatically check for updates')->toContain('There is no setting to turn this off.'); - - // AI requests go to the provider, not to TablePro. - expect($mac)->toContain('never through TablePro'); - - $ios = legalSection('privacy', 'en', 'ios-app'); - - expect($ios)->toContain('**Nothing goes to TablePro unless you turn on Share Usage Data**'); - - // Both reports describe the connections that are open, and the iPhone report's license field is always off. - expect($mac)->toContain('of your open connections')->toContain('how many connections are open'); - expect($ios)->toContain('of your open connections')->toContain('always reports that no license is activated'); -}); - -it('scopes the license checks to old builds, whose usage report describes the machine ID', function (string $locale): void { - // The accounts build drops LicenseAPIClient (tablepro-cloud clients.md, macOS Code); its sign-in is #mac-account. - expect(legalSection('privacy', $locale, 'mac-license'))->toContain('](#mac-usage-report)'); - expect(strstr(legalSection('privacy', $locale, 'mac-usage-report'), '`api.tablepro.app`'))->toContain('SHA-256'); -})->with(legalLocales()); - -it('names both PostHog hosts the accounts build contacts', function (string $locale): void { - // posthog-ios 3.92.7 fetches /config from the assets host of eu.i.posthog.com (PostHogApi.getRemoteConfigRequest). - expect(legalSection('privacy', $locale, 'mac-usage-report'))->toContain('`eu.i.posthog.com`')->toContain('`eu-assets.i.posthog.com`'); -})->with(legalLocales()); - -it('lists the first query for the accounts build and the iPhone report, never for the old Mac report', function (string $locale): void { - // Old Mac builds never record it: MacAnalyticsProvider.markFirstQueryExecuted has no caller at v0.78.0. The accounts build sends - // first_query_executed with database_type (TablePro m16/accounts 4399ab89c), and the iPhone app reports it. - expect(LEGAL_FIRST_QUERY)->toHaveKey($locale); - $usage = legalSection('privacy', $locale, 'mac-usage-report'); - $old = strstr($usage, '`api.tablepro.app`'); - - expect($old)->not->toBeFalse()->not->toContain(LEGAL_FIRST_QUERY[$locale]); - expect(strstr($usage, '`api.tablepro.app`', true))->toContain(LEGAL_FIRST_QUERY[$locale]); - expect(legalSection('privacy', $locale, 'ios-app'))->toContain(LEGAL_FIRST_QUERY[$locale]); -})->with(legalLocales()); - -it('sets no retention period the server does not keep', function (string $locale, string $serverLogs): void { - $retention = legalSection('privacy', $locale, 'retention'); - - // The previous policy promised 90 days for server logs and "aggregated" analytics; neither was true. - preg_match('/^- \*\*' . preg_quote($serverLogs, '/') . '\*\*.*$/m', $retention, $logs); - expect($logs[0] ?? '')->not->toBe('')->not->toMatch('/\d/'); - Assert::assertStringNotContainsStringIgnoringCase('aggregated', legalSource('privacy', $locale)); -})->with([ - 'English' => ['en', 'Server logs'], - 'Vietnamese' => ['vi', 'Nhật ký máy chủ'], -]); - -it('keeps the retention periods tablepro-cloud states', function (): void { - $retention = legalSection('privacy', 'en', 'retention'); - - // data-model.md, Retention. - expect($retention)->toContain('a deleted item keeps its last version') - ->toContain('are deleted 7 days later. Portal sessions last 30 days.') - ->toContain('**Notifications from {merchant}, Amazon SES and Apple**: kept.') - ->toContain('**Devices**: kept, with those you removed, until you delete your account.') - ->toContain('We have not set a time limit for them in PostHog.') - ->toContain("**Server logs**: our web server's and our server's logs, kept on our server for security and troubleshooting."); - expect(legalSource('privacy', 'en'))->not->toContain('180 days')->not->toContain('for 90 days, so every Mac'); -}); - -it('states what tablepro-cloud keeps outside its own tables', function (): void { - // migration.md: a pg_dump of the whole Laravel database to R2, kept indefinitely; no document says it is encrypted. - $retention = legalSection('privacy', 'en', 'retention'); - expect($retention)->toContain('a copy of its whole database is kept indefinitely') - ->toContain('What our previous server held about you stays in its archive') - ->not->toContain('encrypted archive'); - - // data-model.md, Retention: a sign-in email job holds the raw link and code until River cleans it. - expect(legalSection('privacy', 'en', 'security'))->not->toContain('only as hashes') - ->toContain('a day after the email is sent, or 7 days after we give up sending it'); - - // migration.md: every Laravel subscriber not unsubscribed is imported subscribed, confirmed or not. - expect(legalSection('privacy', 'en', 'newsletter'))->toContain('confirmed or not'); - - // clients.md: the Mac app sends usage data to PostHog itself, so PostHog gets the request's IP address. - expect(legalSection('privacy', 'en', 'mac-usage-report'))->toContain('PostHog receives your Mac\'s IP address with each request'); - expect(legalSection('privacy', 'en', 'sharing'))->toContain('reaches PostHog with your Mac\'s IP address'); -}); - -it('describes the account portal tablepro-cloud runs, its sign-in and what it loads', function (): void { - $account = legalSection('privacy', 'en', 'account'); - - expect($account)->toContain('Apple, GitHub or Google')->toContain('passkeys')->toContain('6-digit code') - ->toContain('both expire after 15 minutes') - ->toContain('download your account\'s data as a JSON file') - ->toContain('no Google Analytics, no Cloudflare Web Analytics') - ->toContain('a purchase made there opens {merchant}\'s checkout over the page.'); - - expect(legalSection('privacy', 'en', 'website'))->toContain('**Newsletter check.**')->toContain('`challenges.cloudflare.com`'); - expect(legalSection('privacy', 'en', 'sharing'))->toContain('**Amazon Web Services**')->toContain('**PostHog** (EU Cloud)') - ->not->toContain('An email delivery provider'); - expect(legalSection('privacy', 'en', 'transfers'))->toContain('PostHog processes it in the European Union.'); -}); - -it('names Amazon SES as the sender of every email and the newsletter\'s open and click tracking', function (string $locale): void { - // tablepro-cloud M24: SES in ap-southeast-1 sends both streams and tracks newsletter opens and clicks on links.news.tablepro.app. - expect(legalSource('privacy', $locale))->not->toContain('Resend'); - expect(legalSection('privacy', $locale, 'sharing'))->toContain('**Amazon Web Services**')->toContain('Amazon SES'); - expect(legalSection('privacy', $locale, 'transfers'))->toContain('Amazon Web Services'); - expect(legalSection('privacy', $locale, 'newsletter'))->toContain('`links.news.tablepro.app`'); - expect(legalSection('privacy', $locale, 'retention'))->toContain('Amazon SES'); -})->with(legalLocales()); - -it('covers the documentation site and its own analytics question', function (string $locale): void { - // The docs once set GA cookies on .tablepro.app with no consent; their bar is English only, so every language names its controls in English. - $intro = strtok(YamlFrontMatter::parse(legalSource('privacy', $locale))->body(), "\n"); - $website = legalSection('privacy', $locale, 'website'); - - expect($intro)->toContain('docs.tablepro.app'); - expect($website)->toContain('docs.tablepro.app')->toContain('Google Fonts')->toContain('**Allow**')->toContain('**Cookie settings**'); - expect($website)->toContain('`_ga_`')->toContain('`mintlify_anonymous_id`'); - expect(legalSection('privacy', $locale, 'cookies'))->toContain('`mintlify_anonymous_id`'); - expect(legalSection('privacy', $locale, 'sharing'))->toContain('**Mintlify**'); - expect(legalSection('privacy', $locale, 'transfers'))->toContain('Mintlify'); -})->with(legalLocales()); - -it('discloses Cloudflare Web Analytics and states Google\'s default retention', function (string $locale, string $twoMonths, string $sixMonths): void { - // The GA4 property keeps Google's default of 2 months; it was never set to 14. - $website = legalSection('privacy', $locale, 'website'); - $retention = legalSection('privacy', $locale, 'retention'); - - expect($website)->toContain('**Cloudflare Web Analytics.**')->toContain('`static.cloudflareinsights.com`'); - expect(legalSection('privacy', $locale, 'summary'))->toContain('Cloudflare Web Analytics'); - expect(legalSection('privacy', $locale, 'cookies'))->toContain('Cloudflare Web Analytics'); - expect(legalSection('privacy', $locale, 'lawful-basis'))->toContain('Cloudflare Web Analytics'); - expect(legalSection('privacy', $locale, 'sharing'))->toContain('Cloudflare Web Analytics'); - expect($retention)->toContain('**Cloudflare Web Analytics**')->toContain($sixMonths); - expect($website)->toContain($twoMonths); - expect($retention)->toContain($twoMonths); -})->with([ - 'English' => ['en', '2 months', 'six months'], - 'Vietnamese' => ['vi', '2 tháng', 'sáu tháng'], -]); - -it('names Polar as merchant of record and no other payment route', function (string $document, string $locale): void { - $source = legalSource($document, $locale); - - foreach (['LemonSqueezy', 'Lemon Squeezy', 'SePay', 'bank transfer', 'chuyển khoản', 'PPP', 'VND', 'payment processor', 'depending on your region'] as $needle) { - Assert::assertStringNotContainsStringIgnoringCase($needle, $source, "legal/{$locale}/{$document}.md mentions \"{$needle}\""); - } - - expect(mb_stripos($source, 'merchant of record'))->not->toBeFalse(); -})->with(function (): array { - $cases = []; - - foreach (['privacy', 'terms', 'refund-policy'] as $document) { - foreach (legalLocales() as $locale) { - $cases["{$locale}/{$document}"] = [$document, $locale]; - } - } - - return $cases; -}); - -it('makes the terms agree with priority support, the AGPL and the merchant of record', function (): void { - $terms = legalSource('terms', 'en'); - $pricing = json_decode((string) file_get_contents(resource_path('data/pricing.json')), true); - - // Spec §0: Team emails are answered first, within one business day, and the old "no obligation to provide support" yields to it. - expect($terms)->toContain('emails from Team customers are answered first, within one business day'); - expect($pricing['tiers']['team']['prioritySupport']['responseBusinessDays'])->toBe(1); - expect(legalSection('terms', 'en', 'updates'))->toContain('Apart from the priority support above'); - - // AGPLv3 §2, §4-§6 and §13: running carries no conditions; conveying and network use of a modified version do. - expect($terms)->toContain('The AGPL places no conditions on running TablePro')->not->toContain('not to using it'); - - // A seat is a person on the account's devices (tablepro-cloud product.md, Devices), and the update feed is not ours. - // Old builds activate with a License Key over /v1 until its sunset (tablepro-cloud api.md, /v1). - expect($terms)->toContain('Each seat is for one person, on up to {deviceLimit} Macs') - ->toContain('signed in to your TablePro account or, in versions before TablePro accounts, activated with its License Key') - ->not->toContain('activated Mac') - ->not->toContain('update server'); - - // The buyer pays Polar, not us. - expect($terms)->not->toContain('paid us'); -}); - -it('binds only the services, never the use of the apps, which the AGPLv3 alone licenses', function (): void { - $terms = legalSource('terms', 'en'); - $intro = strtok(YamlFrontMatter::parse($terms)->body(), "\n"); - - // AGPLv3 section 9: running a copy needs no acceptance, and section 10 forbids adding conditions to it. - expect($intro)->toContain('you do not need to accept these terms to download, install or run them') - ->not->toContain('By downloading'); - - foreach (['acceptable-use', 'indemnification', 'termination', 'changes', 'governing-law'] as $id) { - expect(legalSection('terms', 'en', $id))->not->toBe('')->not->toContain('Application'); - } - - expect(legalSection('terms', 'en', 'responsibilities'))->not->toContain('sole responsibility')->not->toContain('backing up'); - expect(legalSection('terms', 'en', 'definitions'))->not->toContain('usage reports'); - expect(legalSection('terms', 'en', 'open-source'))->not->toContain('over the source code'); - - // The warranty and liability position for the apps is the AGPL's own. - expect(legalSection('terms', 'en', 'warranty'))->toContain('section 15 of the AGPLv3'); - expect(legalSection('terms', 'en', 'liability'))->toContain('section 16 of the AGPLv3'); -}); - -it('points every language at the AGPL for the apps', function (string $locale): void { - $intro = strtok(YamlFrontMatter::parse(legalSource('terms', $locale))->body(), "\n"); - - expect(YamlFrontMatter::parse(legalSource('terms', $locale))->matter('updatedAt'))->toBe('2026-11-04'); - expect($intro)->toContain('AGPLv3'); - expect(legalSection('terms', $locale, 'acceptable-use'))->not->toContain('AGPL'); - expect(legalSection('terms', $locale, 'warranty'))->toContain('AGPLv3')->toMatch('/(?toContain('AGPLv3')->toMatch('/(?with(legalLocales()); - -it('keeps the refund window from pricing.json and tells a subscriber how to stop renewals', function (string $locale): void { - $source = legalSource('refund-policy', $locale); - - expect($source)->toContain('{refundDays}'); - Assert::assertDoesNotMatchRegularExpression('/\b7[\s-](days?|ngày)/u', $source); - expect($source)->toContain('{portal}')->toContain('({account})')->not->toContain('/account?locale='); - expect(app(LegalDocuments::class)->render('refund-policy', $locale)['html'])->toContain("href=\"https://account.tablepro.app/?locale={$locale}\""); - - // A full refund ends the license at once; a Mac stops at its next check, an old build within {revalidateDays}. - expect($source)->toContain('{offlineDays}')->toContain('{revalidateDays}'); - Assert::assertStringNotContainsStringIgnoringCase('machines deactivate', $source); -})->with(['en', 'vi']); - -it('refunds each monthly or yearly renewal within the window of its own charge, in the policy, on the pricing page and in the FAQ', function (string $locale): void { - $phrase = LEGAL_RENEWAL_REFUND[$locale]; - $days = (string) json_decode(File::get(resource_path('data/pricing.json')), true)['refund']['days']; - $policy = YamlFrontMatter::parse(legalSource('refund-policy', $locale)); - $faq = collect(json_decode(File::get(resource_path("data/content/{$locale}/faq.json")), true)['groups']) - ->flatMap(fn(array $group): array => $group['items']) - ->firstWhere('id', 'refunds'); - - preg_match('/\brefund"?:\s*\{(.*?)\n\s*\}/s', File::get(resource_path("js/i18n/messages/{$locale}/pricing.ts")), $catalog); - preg_match_all('/:\s*(["\'])(.+?)\1,?\s*$/m', $catalog[1] ?? '', $forms); - - $texts = [ - 'refund-policy.md description' => [(string) $policy->matter('description'), '{refundDays}'], - 'refund-policy.md intro' => [(string) strtok($policy->body(), "\n"), '{refundDays}'], - 'pricing.json refunds' => [json_decode(File::get(resource_path("data/content/{$locale}/pricing.json")), true)['refunds']['body'], '{refundDays}'], - 'faq.json refunds' => [$faq['answer'][0] ?? '', '{refundDays}'], - ]; - - expect($forms[2])->not->toBeEmpty(); - - foreach ($forms[2] as $index => $form) { - $texts["pricing.ts refund form {$index}"] = [$form, '{count}']; - } - - foreach ($texts as $where => [$text, $slot]) { - if (in_array($where, ['pricing.json refunds', 'faq.json refunds'], true)) { - [$renewals, $chargeWindow] = MARKETING_RENEWAL_REFUND[$locale]; - expect($text)->toContain($renewals)->toContain($chargeWindow); - $at = mb_strpos($text, $renewals); - } else { - $at = mb_strpos($text, $phrase); - } - - expect($at)->not->toBeFalse("{$locale} {$where} does not refund a monthly or yearly renewal"); - expect(mb_substr($text, (int) $at))->toContain($slot); - expect($text)->not->toMatch('/(?with(legalLocales()); - -it('names the publisher from facts.json as the provider in the terms and the data controller in the privacy policy', function (string $locale): void { - $publisher = app(SiteFacts::class)->publisher($locale); - - expect($publisher)->not->toBeNull(); - - foreach (['terms' => 'definitions', 'privacy' => 'controller'] as $document => $section) { - expect(legalSection($document, $locale, $section)) - ->toContain('{publisherName}') - ->toContain('{publisherCity}') - ->toContain('{publisherCountry}'); - - Assert::assertStringNotContainsString($publisher['name'], legalSource($document, $locale)); - Assert::assertStringNotContainsString($publisher['city'], legalSource($document, $locale)); - - expect(app(LegalDocuments::class)->render($document, $locale)['html']) - ->toContain($publisher['name']) - ->toContain($publisher['city']) - ->toContain($publisher['country']); - } - - expect(legalSection('privacy', $locale, 'controller'))->toContain('[{email}](mailto:{email})'); -})->with(legalLocales()); - -it('describes the publisher as one person, never a company or a team', function (): void { - $offences = []; - - foreach ([...File::allFiles(resource_path('data/legal')), ...File::allFiles(resource_path('data/content'))] as $file) { - // A capital "Team" before TablePro is the reader's: "Kann mein Team TablePro …". - if (preg_match_all('/当社|弊社|당사|저희|本公司|people who run TablePro|TablePro (?:[Tt]eam|チーム|팀|团队|團隊)|TablePro-Team|(?:team|[Tt]im|équipe|equipe|equipo de|[Đđ]ội ngũ) TablePro/u', $file->getContents(), $matches) > 0) { - $offences[] = $file->getRelativePathname() . ': ' . implode(', ', array_unique($matches[0])); - } - } - - expect($offences)->toBe([]); -}); - -it('limits access to the systems to the publisher and the providers that receive data', function (string $locale): void { - expect(legalSection('privacy', $locale, 'security'))->toContain('{publisherName}')->toContain('](#sharing)'); -})->with(legalLocales()); - -it('points a vulnerability report in the privacy policy at the security page', function (string $locale): void { - $prefix = Locales::prefixFor($locale); - $href = ($prefix === null ? '' : "/{$prefix}") . '/security#report'; - $security = json_decode(File::get(resource_path("data/content/{$locale}/security.json")), true); - - expect(legalSection('privacy', $locale, 'security'))->toContain("]({$href})"); - expect(app(LegalDocuments::class)->render('privacy', $locale)['html'])->toContain('href="' . $href . '"'); - expect(array_column($security['sections'], 'id'))->toContain('report'); - expect(app(PageRegistry::class)->find('landing.security', [])->renderLocales)->toContain($locale); -})->with(legalLocales()); - -it('links each document to pages in its own language', function (string $locale): void { - $prefix = Locales::prefixFor($locale); - $offences = []; - - foreach (['privacy', 'terms', 'refund-policy'] as $document) { - preg_match_all('/\]\((\/[^)\s]*)\)/', legalSource($document, $locale), $links); - - foreach ($links[1] as $href) { - // The platform's paths carry the language in `?locale=`, never a prefix. - if (preg_match('#^/account(\?|$)#', $href) === 1) { - continue; - } - - // The brand guidelines are published in English alone. - if ($href === '/brand') { - continue; - } - - $segment = explode('/', ltrim($href, '/'))[0]; - $inLocale = $prefix === null ? ! in_array($segment, Locales::codes(), true) : $segment === $prefix; - - if (! $inLocale) { - $offences[] = "{$document}.md links {$href}"; - } - } - } - - expect($offences)->toBe([]); -})->with(legalLocales()); - -it('notes on Vietnamese pages that the English version prevails', function (): void { - $chrome = json_decode((string) file_get_contents(resource_path('data/content/vi/legal.json')), true); - - expect($chrome['notice']['body'])->toContain('bản tiếng Anh được ưu tiên áp dụng')->toContain(''); -}); - -it('server-renders the cookie settings button inside the cookies section', function (string $path): void { - $html = ssrHtml($path); - - $start = strpos($html, 'id="cookies"'); - $end = strpos($html, 'id="lawful-basis"', (int) $start); - - expect($start)->not->toBeFalse(); - expect(substr($html, (int) $start, (int) $end - (int) $start))->toMatch('/