Skip to content
Merged
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions functions/lib/itunes-banner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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 + '.';
}
Expand Down
27 changes: 27 additions & 0 deletions public/invite/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,36 @@
html[data-platform="ios"] .only-ios { display: block; }
[hidden] { display: none !important; }
</style>
<noscript>
<!-- Without JS the loading state would sit there for ever: nothing ever
resolves the code. style-src allows inline (see _headers). -->
<style>
#state-loading {
display: none;
}
</style>
</noscript>
</head>
<body>
<main class="card">
<noscript>
<section>
<h1 lang="de">JavaScript ist deaktiviert</h1>
<p lang="de">
Diese Seite löst deinen Code über die RealUnit-App auf und braucht dafür JavaScript.
Aktiviere JavaScript und lade die Seite neu. Die App selbst findest du über die Links
unten.
</p>
<!-- Both languages: without JS nothing can read ?lang=en, and the
middleware may still have flipped the document to lang="en".
Only this block and the store links below are visible without JS,
so the copy points at those and promises no other control. -->
<p lang="en">
This page resolves your code through the RealUnit app and needs JavaScript. Enable
JavaScript and reload the page. The app itself is linked below.
</p>
</section>
</noscript>
<section id="state-loading" role="status" aria-live="polite" aria-busy="true">
<h1 id="loading-title">Einladung wird geladen…</h1>
<p id="loading-body">Einen Moment bitte.</p>
Expand Down
14 changes: 12 additions & 2 deletions public/js/lib/invite-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 + '.';
}
Expand Down
27 changes: 27 additions & 0 deletions public/promo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,36 @@
html[data-platform="ios"] .only-ios { display: block; }
[hidden] { display: none !important; }
</style>
<noscript>
<!-- Without JS the loading state would sit there for ever: nothing ever
resolves the code. style-src allows inline (see _headers). -->
<style>
#state-loading {
display: none;
}
</style>
</noscript>
</head>
<body>
<main class="card">
<noscript>
<section>
<h1 lang="de">JavaScript ist deaktiviert</h1>
<p lang="de">
Diese Seite löst deinen Code über die RealUnit-App auf und braucht dafür JavaScript.
Aktiviere JavaScript und lade die Seite neu. Die App selbst findest du über die Links
unten.
</p>
<!-- Both languages: without JS nothing can read ?lang=en, and the
middleware may still have flipped the document to lang="en".
Only this block and the store links below are visible without JS,
so the copy points at those and promises no other control. -->
<p lang="en">
This page resolves your code through the RealUnit app and needs JavaScript. Enable
JavaScript and reload the page. The app itself is linked below.
</p>
</section>
</noscript>
<section id="state-loading" role="status" aria-live="polite" aria-busy="true">
<h1 id="loading-title">Promo-Code wird geladen…</h1>
<p id="loading-body">Einen Moment bitte.</p>
Expand Down
14 changes: 14 additions & 0 deletions test/invite-core.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,20 @@ describe('URLs', () => {
expect(enTitle).toContain('<title>RealUnit — Invitation AB12CD</title>');
});

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 = '<html lang="de"><title>RealUnit — Einladung</title>';
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 =
'<meta property="og:image:alt" content="RealUnit" />' +
Expand Down
110 changes: 110 additions & 0 deletions test/itunes-banner-function.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
'<html lang="de"><title>RealUnit — Einladung</title>' +
'<meta property="og:title" content="RealUnit — Einladung" />' +
'<meta property="og:image:alt" content="RealUnit" />' +
'<meta property="og:locale" content="de_CH" />' +
'<meta property="og:description" content="Öffne die RealUnit-App mit diesem Code." />';

// 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('<html lang="en">');
expect(en).toContain('<title>RealUnit — Invitation</title>');
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('<title>RealUnit — Promo code</title>');

// Without ?lang=en the German shell must be left exactly as it is.
const de = injectLandingFromRequestUrl(shell, 'https://realunit.app/invite/');
expect(de).toContain('<title>RealUnit — Einladung</title>');
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 = '<meta property="og:image:alt" content="RealUnit" />';
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'));
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
60 changes: 60 additions & 0 deletions tests/behavior.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <noscript>, getByText does not — its text engine skips that subtree.
const heading = page.getByRole('heading', { name: 'JavaScript ist deaktiviert' });
await expect(heading).toBeVisible();
await expect(heading).toHaveAttribute('lang', 'de');
// Full text, not a substring: a truncated or half-translated paragraph
// would otherwise stay green.
// toHaveText matches textContent, which display:none leaves untouched —
// so the visibility of each paragraph is asserted separately.
await expect(page.locator('main noscript p[lang="de"]')).toBeVisible();
await expect(page.locator('main noscript p[lang="de"]')).toHaveText(
'Diese Seite löst deinen Code über die RealUnit-App auf und braucht dafür JavaScript. ' +
'Aktiviere JavaScript und lade die Seite neu. Die App selbst findest du über die Links ' +
'unten.',
);
await expect(page.locator('main noscript p[lang="en"]')).toBeVisible();
await expect(page.locator('main noscript p[lang="en"]')).toHaveText(
'This page resolves your code through the RealUnit app and needs JavaScript. Enable ' +
'JavaScript and reload the page. The app itself is linked below.',
);
// The copy tells the visitor the app is linked below, so the links have
// to be there without JS.
await expect(page.locator('nav.stores a[data-store="apple"]')).toBeVisible();
await expect(page.locator('nav.stores a[data-store="play"]')).toBeVisible();
}
});
});

test.describe('the no-JavaScript notice stays inside <noscript>', () => {
test('a visitor with JavaScript never sees it', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'desktop-only check');
// Dropping the <noscript> wrapper would show every visitor a warning about
// JavaScript being off. Nothing else in the suite would go red for that:
// the case above runs only with scripting disabled, where the wrapper's
// children are parsed as ordinary markup either way.
// ?mock= keeps this off the network; the notice is a property of the
// shell, not of the lookup result.
for (const path of ['/invite/AB12CD?mock=loading', '/promo/EVT1?mock=loading']) {
await page.goto(path);
await expect(page.locator('main noscript')).toHaveCount(1);
await expect(page.getByRole('heading', { name: 'JavaScript ist deaktiviert' })).toHaveCount(
0,
);
}
});
});

test.describe('invite and promo landing', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'desktop-only invite-flow checks');
Expand Down
9 changes: 7 additions & 2 deletions tests/helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,13 @@ export async function installVisualDeterminism(page, { platform } = {}) {
// Wait until the page has reached a stable visual state: network idle, fonts
// ready, and a short settle so any state transition (the ?mock hook renders after
// ~400ms) has landed.
export async function settle(page) {
export async function settle(page, { scripting = true } = {}) {
await page.waitForLoadState('networkidle');
await page.evaluate(() => document.fonts && document.fonts.ready);
// Font readiness is only observable from inside the page. A view rendered with
// scripting disabled has no page context to ask, and its fonts are already in
// by the networkidle above, so the question is skipped rather than swallowed.
if (scripting) {
await page.evaluate(() => document.fonts && document.fonts.ready);
}
await page.waitForTimeout(600);
}
Loading
Loading