diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 74ed071..d2719b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,7 +129,7 @@ npm run e2e:docker:update # regenerate baselines after an intentional UI chang The visual matrix lives in `tests/pages.mjs` (`VIEWS`), which is the single source of truth — do not maintain a second list here. It currently covers six families: the invite and promo landings (each in their loading, resolved, -invalid, missing-code and platform-matched variants), the confirm-page states, +invalid, missing-code, platform-matched and JS-less variants), the confirm-page states, the account-merge pages, the home landing in its equal-badge and platform-matched layouts, and the 404 page — across `desktop-chromium`, `tablet-chromium` and `mobile-safari`. `check:visual` enforces that every view × applicable viewport diff --git a/functions/lib/itunes-banner.js b/functions/lib/itunes-banner.js index b18c0b1..6711830 100644 --- a/functions/lib/itunes-banner.js +++ b/functions/lib/itunes-banner.js @@ -426,7 +426,17 @@ export function parseLangFromUrl(urlLike) { /** Crawlers snapshot og:title / twitter:title from the HTML bytes. Names wait for lookup JS. */ export function shareTitle(kind, code, lang) { - if (!kind || !code) return null; + // Keep the kind guard first: this function is exported, and a missing kind + // must not silently render as an invitation. + if (!kind) return null; + // Without a code there is nothing code-specific to say, but ?lang=en still + // flips html lang and og:locale — leaving the German shell text under an + // English locale for crawlers. Fall back to a generic English title, using + // the same strings as I18N.en in invite-core.js. + if (!code) { + if (lang !== 'en') return null; + return kind === 'promo' ? 'RealUnit — Promo code' : 'RealUnit — Invitation'; + } if (lang === 'en') { return kind === 'promo' ? 'RealUnit — Promo code ' + code : 'RealUnit — Invitation ' + code; } @@ -458,6 +468,10 @@ export function injectShareTitleHtml(html, kind, code, lang) { /** Crawlers snapshot og:image:alt / twitter:image:alt from the HTML bytes. */ export function injectShareImageAltHtml(html, kind, code, lang) { if (typeof html !== 'string') return html; + // Alt text describes the image, and without a code the image is the generic + // og.png the shell already labels "RealUnit". The codeless English title + // from shareTitle is a page title, not a picture caption, so it stops here. + if (!code) return html; const alt = shareTitle(kind, code, lang); if (!alt) return html; const safe = htmlEscape(alt); @@ -477,7 +491,10 @@ export function injectShareImageAltHtml(html, kind, code, lang) { /** Crawlers snapshot og:description from the HTML bytes. Names wait for lookup JS. */ export function shareDescription(code, lang) { - if (!code) return null; + // Same reason as shareTitle: an English locale must not keep German copy. + // Same string as I18N.en['doc.desc'] in invite-core.js, so the crawler + // snapshot and the JS-rendered page do not disagree. + if (!code) return lang === 'en' ? 'Open the RealUnit app with this code.' : null; if (lang === 'en') return 'Open the RealUnit app with code ' + code + '.'; return 'Öffne die RealUnit-App mit dem Code ' + code + '.'; } diff --git a/public/invite/index.html b/public/invite/index.html index d4c8943..09d74f5 100644 --- a/public/invite/index.html +++ b/public/invite/index.html @@ -147,9 +147,36 @@ html[data-platform="ios"] .only-ios { display: block; } [hidden] { display: none !important; } +
+

Einladung wird geladen…

Einen Moment bitte.

diff --git a/public/js/lib/invite-core.js b/public/js/lib/invite-core.js index 5f864c0..5396108 100644 --- a/public/js/lib/invite-core.js +++ b/public/js/lib/invite-core.js @@ -1005,7 +1005,16 @@ // Crawlers snapshot og:title / twitter:title from the HTML bytes. // Names wait for lookup JS; the request URL can already name the code. function shareTitle(kind, code, lang) { - if (!kind || !code) return null; + // Kept byte-identical to shareTitle in functions/lib/itunes-banner.js; + // test/itunes-banner-function.test.mjs pins the two against each other. + // The codeless branch is unreachable through this file's own injectors — + // parseCodeFromLocation returns null without a code — but the function is + // exported, so it must not answer differently from the server module. + if (!kind) return null; + if (!code) { + if (lang !== 'en') return null; + return kind === 'promo' ? 'RealUnit — Promo code' : 'RealUnit — Invitation'; + } if (lang === 'en') { return kind === 'promo' ? 'RealUnit — Promo code ' + code : 'RealUnit — Invitation ' + code; } @@ -1054,7 +1063,8 @@ } function shareDescription(code, lang) { - if (!code) return null; + // Kept byte-identical to shareDescription in functions/lib/itunes-banner.js. + if (!code) return lang === 'en' ? 'Open the RealUnit app with this code.' : null; if (lang === 'en') return 'Open the RealUnit app with code ' + code + '.'; return 'Öffne die RealUnit-App mit dem Code ' + code + '.'; } diff --git a/public/promo/index.html b/public/promo/index.html index 1c0a5db..01d1b61 100644 --- a/public/promo/index.html +++ b/public/promo/index.html @@ -147,9 +147,36 @@ html[data-platform="ios"] .only-ios { display: block; } [hidden] { display: none !important; } +
+

Promo-Code wird geladen…

Einen Moment bitte.

diff --git a/test/invite-core.test.mjs b/test/invite-core.test.mjs index 16b87a7..63489ab 100644 --- a/test/invite-core.test.mjs +++ b/test/invite-core.test.mjs @@ -745,6 +745,20 @@ describe('URLs', () => { expect(enTitle).toContain('RealUnit — Invitation AB12CD'); }); + test('the codeless English copy is exported but unreachable from this file', () => { + // functions/lib/itunes-banner.js answers a codeless ?lang=en landing with + // English copy. These exported helpers agree with it (pinned in + // test/itunes-banner-function.test.mjs), but this module's own injectors + // never get there: parseCodeFromLocation returns null without a code, so + // the browser page is unaffected either way. + expect(parseCodeFromLocation('/invite', '?lang=en', '')).toBeNull(); + expect(shareTitle('invite', null, 'en')).toBe('RealUnit — Invitation'); + expect(shareDescription(null, 'en')).toBe('Open the RealUnit app with this code.'); + const shell = 'RealUnit — Einladung'; + expect(injectShareTitleHtml(shell, '/invite', '?lang=en', '')).toBe(shell); + expect(injectShareDescriptionHtml(shell, '/invite', '?lang=en', '')).toBe(shell); + }); + test('injectShareImageAltHtml writes og:image:alt from the path', () => { const shell = '' + diff --git a/test/itunes-banner-function.test.mjs b/test/itunes-banner-function.test.mjs index 9edaddb..6350db0 100644 --- a/test/itunes-banner-function.test.mjs +++ b/test/itunes-banner-function.test.mjs @@ -560,6 +560,116 @@ describe('referral code injection hardening', () => { }); }); +describe('an English locale without a code keeps English copy', () => { + test('shareTitle falls back to a generic English title', () => { + expect(shareTitle('invite', null, 'en')).toBe('RealUnit — Invitation'); + expect(shareTitle('promo', null, 'en')).toBe('RealUnit — Promo code'); + // German is the shell's own language, so there is nothing to replace. + expect(shareTitle('invite', null, 'de')).toBeNull(); + expect(shareTitle('invite', null, null)).toBeNull(); + // A missing kind stays null even in English: it must not render as an + // invitation just because that is the more common case. + expect(shareTitle(null, null, 'en')).toBeNull(); + }); + + test('shareDescription falls back to a generic English description', () => { + expect(shareDescription(null, 'en')).toBe('Open the RealUnit app with this code.'); + expect(shareDescription(null, 'de')).toBeNull(); + }); + + test('the fallback copy is the same string the page renders', () => { + // Both modules claim in a comment to reuse I18N.en. The literals above pin + // the wording; this pins the claim, so editing the catalogue alone — which + // public/invite/invite.js reads at runtime — turns the suite red instead of + // silently splitting the crawler snapshot from the rendered page. + const en = window.RealUnitInvite.I18N.en; + expect(shareTitle('invite', null, 'en')).toBe(en['doc.title.invite']); + expect(shareTitle('promo', null, 'en')).toBe(en['doc.title.promo']); + expect(shareDescription(null, 'en')).toBe(en['doc.desc']); + }); + + test('a codeless English landing is rewritten end to end, German is untouched', () => { + const shell = + 'RealUnit — Einladung' + + '' + + '' + + '' + + ''; + + // The helpers returning a string is not the point — the point is that the + // bytes a crawler snapshots actually change. + const en = injectLandingFromRequestUrl(shell, 'https://realunit.app/invite/?lang=en'); + expect(en).toContain(''); + expect(en).toContain('RealUnit — Invitation'); + expect(en).toContain('property="og:title" content="RealUnit — Invitation"'); + expect(en).toContain( + 'property="og:description" content="Open the RealUnit app with this code."', + ); + + const promo = injectLandingFromRequestUrl(shell, 'https://realunit.app/promo/?lang=en'); + expect(promo).toContain('RealUnit — Promo code'); + + // Without ?lang=en the German shell must be left exactly as it is. + const de = injectLandingFromRequestUrl(shell, 'https://realunit.app/invite/'); + expect(de).toContain('RealUnit — Einladung'); + expect(de).toContain( + 'property="og:description" content="Öffne die RealUnit-App mit diesem Code."', + ); + }); + + test('a code still wins over the fallback', () => { + expect(shareTitle('promo', 'EVT1', 'en')).toBe('RealUnit — Promo code EVT1'); + expect(shareDescription('EVT1', 'en')).toBe('Open the RealUnit app with code EVT1.'); + }); + + test('the codeless title does not become the image alt', () => { + // og:image:alt describes the picture, and without a code the picture is + // the generic og.png the shell already labels "RealUnit". + const shell = ''; + expect(injectShareImageAltHtml(shell, 'invite', null, 'en')).toBe(shell); + expect(injectShareImageAltHtml(shell, 'invite', 'AB12CD', 'en')).toContain( + 'content="RealUnit — Invitation AB12CD"', + ); + }); +}); + +describe('the browser mirror and the function module say the same thing', () => { + // public/js/lib/invite-core.js carries a second copy of shareTitle and + // shareDescription. Nothing in production calls its HTML injectors, so a + // divergence would not fail any other test — this one pins the pair. + const mirror = window.RealUnitInvite; + const kinds = ['invite', 'promo', null, undefined, '']; + const codes = [null, undefined, '', 'AB12CD', 'EVT1']; + const langs = ['en', 'de', null, undefined, 'fr']; + + test('shareTitle agrees across the whole matrix', () => { + for (const kind of kinds) { + for (const code of codes) { + for (const lang of langs) { + expect([kind, code, lang, mirror.shareTitle(kind, code, lang)]).toEqual([ + kind, + code, + lang, + shareTitle(kind, code, lang), + ]); + } + } + } + }); + + test('shareDescription agrees across the whole matrix', () => { + for (const code of codes) { + for (const lang of langs) { + expect([code, lang, mirror.shareDescription(code, lang)]).toEqual([ + code, + lang, + shareDescription(code, lang), + ]); + } + } + }); +}); + describe('paths the 100% gate now covers on the function module', () => { test('playStoreUrl without a code returns the bare store link', () => { expect(playStoreUrl(null, 'invite')).toBe(playStoreUrl(null, 'promo')); diff --git a/tests/__screenshots__/desktop-chromium/invite-noscript.png b/tests/__screenshots__/desktop-chromium/invite-noscript.png new file mode 100644 index 0000000..cd309d6 Binary files /dev/null and b/tests/__screenshots__/desktop-chromium/invite-noscript.png differ diff --git a/tests/__screenshots__/desktop-chromium/promo-noscript.png b/tests/__screenshots__/desktop-chromium/promo-noscript.png new file mode 100644 index 0000000..cd309d6 Binary files /dev/null and b/tests/__screenshots__/desktop-chromium/promo-noscript.png differ diff --git a/tests/__screenshots__/mobile-safari/invite-noscript.png b/tests/__screenshots__/mobile-safari/invite-noscript.png new file mode 100644 index 0000000..1c5a23d Binary files /dev/null and b/tests/__screenshots__/mobile-safari/invite-noscript.png differ diff --git a/tests/__screenshots__/mobile-safari/promo-noscript.png b/tests/__screenshots__/mobile-safari/promo-noscript.png new file mode 100644 index 0000000..1c5a23d Binary files /dev/null and b/tests/__screenshots__/mobile-safari/promo-noscript.png differ diff --git a/tests/__screenshots__/tablet-chromium/invite-noscript.png b/tests/__screenshots__/tablet-chromium/invite-noscript.png new file mode 100644 index 0000000..96eafc4 Binary files /dev/null and b/tests/__screenshots__/tablet-chromium/invite-noscript.png differ diff --git a/tests/__screenshots__/tablet-chromium/promo-noscript.png b/tests/__screenshots__/tablet-chromium/promo-noscript.png new file mode 100644 index 0000000..96eafc4 Binary files /dev/null and b/tests/__screenshots__/tablet-chromium/promo-noscript.png differ diff --git a/tests/behavior.spec.mjs b/tests/behavior.spec.mjs index 5b71939..6cb9b4b 100644 --- a/tests/behavior.spec.mjs +++ b/tests/behavior.spec.mjs @@ -694,6 +694,66 @@ test.describe('account-merge flow', () => { const REFERRAL_CODE_ENDPOINT = '**/v1/realunit/referral/code/**'; +test.describe('invite and promo landing without JavaScript', () => { + test.use({ javaScriptEnabled: false }); + + test('says why nothing resolves instead of spinning for ever', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'desktop-only check'); + for (const path of ['/invite/AB12CD', '/promo/EVT1']) { + await page.goto(path); + // Nothing can resolve the code, so the loading state must not be the + // only thing on screen. + await expect(page.locator('#state-loading')).toBeHidden(); + // Assert what the visitor can see, not what the bytes contain: a rule + // like `noscript section { display: none }` would keep the markup and + // still leave the page blank. Measured on this Playwright version with + // javaScriptEnabled:false: role and CSS locators do reach into + //