diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 74ed071..63a43a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,8 +9,16 @@ This repo is the **realunit.app** website — public, static. See the `public/` ships verbatim to Cloudflare Pages — what you commit is what gets served. The one exception is the invite/promo HTML: `functions/_middleware.js` rewrites those bytes on the way out so crawlers see the code in - `apple-itunes-app`, `og:*` and the App Links before any script runs. Nothing - else is transformed, and there is no server-side rendering. The dev dependencies exist **only** for the quality gates below + `apple-itunes-app`, `og:*` and the App Links before any script runs. It also + reports a rewritten landing as `200`: Pages resolves `/invite/` to the + code-less shell through the `_redirects` rewrite but keeps the not-found + status of the path that was asked for, and a crawler drops a `404` before it + reads the tags. The promotion is guarded on the two landing marks — `id="state-loading"` and `aria-busy="true"`, which both landings carry together in one tag and no other page the site ships carries together — so the site's + own 404 page keeps saying 404, and HEAD answers with the same status as GET. + Both methods are resolved internally as one full GET, without `Range` / + `If-Range` and without the conditional request headers, because the whole document + is rewritten and the status is decided from its body. The conditional request headers are dropped rather than evaluated, which is a deliberate deviation from RFC 9110 §13.1: a rewritten landing emits no validator to condition on, so the answer is always the current representation and never a 304 or a 412. A pass-through answer keeps the origin's own headers, validator included. `scripts/dev-server.mjs` shares the injection and answers HEAD without a body, but has no promotion, no marker guard and no header stripping: its own routing serves the landings as `200` and never produces the not-found status the promotion exists to correct. Nothing else is transformed, and there is no server-side + rendering. The dev dependencies exist **only** for the quality gates below (formatting, HTML validation, unit tests, screenshots); nothing compiles or bundles the site. - **Invite/promo HTML rewrite is banner, canonical, and store handoff.** Safari, @@ -45,8 +53,9 @@ This repo is the **realunit.app** website — public, static. See the adding any other host to that allowlist needs a reason in the PR. - Inline `style="…"` attributes and ` +
+

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..d3ff8b1 100644 --- a/test/itunes-banner-function.test.mjs +++ b/test/itunes-banner-function.test.mjs @@ -16,6 +16,7 @@ import { shareTitle, injectShareDescriptionHtml, shareDescription, + landingStatus, parseLangFromUrl, injectShareLocaleHtml, injectSiteNameHtml, @@ -32,11 +33,22 @@ describe('shouldRewriteItunesBanner', () => { test('invite and promo HTML paths only', () => { expect(shouldRewriteItunesBanner('/invite')).toBe(true); expect(shouldRewriteItunesBanner('/invite/AB12CD')).toBe(true); + expect(shouldRewriteItunesBanner('/promo')).toBe(true); expect(shouldRewriteItunesBanner('/promo/EVT1')).toBe(true); expect(shouldRewriteItunesBanner('/invite/invite.js')).toBe(false); expect(shouldRewriteItunesBanner('/js/invite-banner.js')).toBe(false); expect(shouldRewriteItunesBanner('/')).toBe(false); expect(shouldRewriteItunesBanner('/.well-known/apple-app-site-association')).toBe(false); + // The prefix has to end at a segment boundary: a path that merely starts + // with the same letters is somebody else's. + expect(shouldRewriteItunesBanner('/invitee')).toBe(false); + expect(shouldRewriteItunesBanner('/invite-old')).toBe(false); + expect(shouldRewriteItunesBanner('/promotion')).toBe(false); + expect(shouldRewriteItunesBanner('/promo-old')).toBe(false); + // A doubled slash makes the first segment empty, so neither the exact + // comparison nor the prefix matches. + expect(shouldRewriteItunesBanner('//invite/AB12CD')).toBe(false); + expect(shouldRewriteItunesBanner('/invites/AB12CD')).toBe(false); }); }); @@ -359,7 +371,7 @@ describe('injectLandingFromRequestUrl', () => { ''; const out = injectLandingFromRequestUrl(shell, 'https://www.realunit.app/invite/AB12CD?mock=1'); expect(out).toContain('app-argument=realunit-wallet://invite/AB12CD'); - expect(out).toContain('content="https://realunit.app/invite/AB12CD"'); + expect(out).toContain('property="og:url" content="https://realunit.app/invite/AB12CD"'); expect(out).toContain('href="https://realunit.app/invite/AB12CD"'); expect(out).toContain('name="twitter:url" content="https://realunit.app/invite/AB12CD"'); expect(out).toContain('property="og:title" content="RealUnit — Einladung AB12CD"'); @@ -389,8 +401,17 @@ describe('injectLandingFromRequestUrl', () => { expect(out).toContain( 'https://play.google.com/store/apps/details?id=swiss.realunit.app&referrer=invite%3DAB12CD', ); - expect(out).toContain('data-android-app'); - expect(out).toContain('data-ios-app'); + // The hrefs, not just the attribute names: swapping the two would send + // Android users to the iOS hand-off and back, and the names alone would + // not notice. + expect(out).toContain( + 'data-android-app href="android-app://swiss.realunit.app/https/realunit.app/invite/AB12CD"', + ); + expect(out).toContain('data-ios-app href="ios-app://6759720010/realunit-wallet/invite/AB12CD"'); + // The iPad card carries the same scheme as the phone one and was asserted + // nowhere; a wrong value there would have gone unnoticed. + expect(out).toContain('name="twitter:app:url:ipad" content="realunit-wallet://invite/AB12CD"'); + expect(out).toContain('name="twitter:app:id:ipad" content="6759720010"'); expect(out).toContain('al:android:url'); expect(out).toContain('property="al:android:url" content="realunit-wallet://invite/AB12CD"'); expect(out).toContain('property="al:android:class" content="swiss.realunit.app.MainActivity"'); @@ -520,6 +541,28 @@ describe('referral code injection hardening', () => { expect(out).toContain('AB"><'); }); + test('an alternate link that already exists is replaced, not doubled', () => { + // The fixtures so far only ever reached the insert branch, because no shell + // ships these links; the replace branch had never run. + for (const existing of [ + '', + '', + // href before the attribute: the upsert has a separate branch for that + // order, and the two fixtures above both leave it unrun. + '', + ]) { + const out = injectLandingFromRequestUrl( + '' + existing + '', + 'https://realunit.app/invite/AB12CD', + ); + expect(out).toContain( + 'href="android-app://swiss.realunit.app/https/realunit.app/invite/AB12CD"', + ); + expect(out).not.toContain('android-app://old'); + expect(out.match(/data-android-app/g)).toHaveLength(1); + } + }); + test('injectShareTitleHtml escapes a raw code and never breaks the attribute', () => { const out = injectShareTitleHtml(base, 'invite', 'A"> { }); }); +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 copy must be left as it is. The pass still + // adds og:site_name, which is language-independent, so this checks the copy + // rather than byte equality. + 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('landingStatus', () => { + // Both marks, as the shipped shells carry them. + const shell = '
'; + + test('promotes a not-found landing to found', () => { + expect(landingStatus(404, shell)).toBe(200); + }); + + test('leaves every other status alone, including its 4xx neighbours', () => { + expect(landingStatus(200, shell)).toBe(200); + expect(landingStatus(500, shell)).toBe(500); + expect(landingStatus(302, shell)).toBe(302); + // Only 404 is the Pages artefact this exists for. Widening the condition to + // the whole 4xx range would promote a real refusal or a withdrawn code. + expect(landingStatus(403, shell)).toBe(403); + expect(landingStatus(410, shell)).toBe(410); + expect(landingStatus(451, shell)).toBe(451); + }); + + test('refuses to promote a body that is not a landing', () => { + // The site's own 404 page must keep saying 404 rather than look healthy. + expect(landingStatus(404, 'Seite nicht gefunden — RealUnit')).toBe(404); + // One mark alone is not a landing shell. + expect(landingStatus(404, '
')).toBe(404); + expect(landingStatus(404, '
')).toBe(404); + expect(landingStatus(404, '')).toBe(404); + expect(landingStatus(404, null)).toBe(404); + expect(landingStatus(404, undefined)).toBe(404); + }); +}); + +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/test/middleware.test.mjs b/test/middleware.test.mjs new file mode 100644 index 0000000..60cc4a6 --- /dev/null +++ b/test/middleware.test.mjs @@ -0,0 +1,781 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, test } from 'vitest'; +import { onRequest } from '../functions/_middleware.js'; +import { isLandingShell } from '../functions/lib/itunes-banner.js'; + +// The real files, not a hand-written stand-in. The status promotion keys on a +// marks that live in the landing shells and must never appear in the site's +// 404 page; a synthetic fixture would keep passing after someone moved that +// marker, and production would answer 404 again with nothing going red. +// Resolved from the project root: vitest runs from there, and the jsdom +// environment does not give this module a usable import.meta.url. +const page = (name) => readFileSync(resolve('public', name), 'utf8'); + +// Every page the site ships, named the way page() wants them. +const shippedHtml = (dir = 'public') => + readdirSync(resolve(dir), { withFileTypes: true }).flatMap((entry) => { + const path = `${dir}/${entry.name}`; + if (entry.isDirectory()) return shippedHtml(path); + return entry.name.endsWith('.html') ? [path.slice('public/'.length)] : []; + }); + +// The header values public/_headers really sets, read from the file so the +// fixture cannot drift from production. This checks that the middleware passes +// them through — a dropped security header does not show up in coverage, which +// measures execution and not values. It does not review the policy itself: +// changing _headers moves both sides together, by design. +function headerRule(pattern, name) { + const rules = page('_headers').split(/\r?\n/); + const start = rules.findIndex((line) => line.trim() === pattern); + if (start < 0) throw new Error(`public/_headers has no rule for ${pattern}`); + for (const line of rules.slice(start + 1)) { + if (!line.startsWith(' ') && !line.startsWith('\t')) break; + const [key, ...rest] = line.trim().split(':'); + if (key.toLowerCase() === name) return rest.join(':').trim(); + } + throw new Error(`public/_headers sets no ${name} for ${pattern}`); +} + +const SITE_HEADERS = { + 'content-security-policy': headerRule('/*', 'content-security-policy'), + 'x-content-type-options': headerRule('/*', 'x-content-type-options'), + 'x-frame-options': headerRule('/*', 'x-frame-options'), + 'referrer-policy': headerRule('/*', 'referrer-policy'), + 'cache-control': headerRule('/invite/*', 'cache-control'), +}; +const SHELL = page('invite/index.html'); +const PROMO_SHELL = page('promo/index.html'); +const NOT_FOUND_PAGE = page('404.html'); +// .length counts UTF-16 units; a Content-Length counts bytes, and these pages +// carry multi-byte characters. +const bytes = (text) => new TextEncoder().encode(text).length; +const SHELL_BYTES = bytes(SHELL); +const NOT_FOUND_BYTES = bytes(NOT_FOUND_PAGE); + +function context({ + url, + method = 'GET', + status = 404, + body = SHELL, + type = 'text/html; charset=utf-8', + requestHeaders = {}, +}) { + const headers = new Headers({ + 'content-type': type, + 'content-length': String(bytes(body)), + ...SITE_HEADERS, + }); + const forwarded = []; + const next = (request) => { + forwarded.push(request || ctx.request); + // 204, 205 and 304 cannot be built with a body — which is the very thing + // the middleware has to respect when it rebuilds the response. + const upstream = status === 204 || status === 205 || status === 304 ? null : body; + return Promise.resolve( + new Response(upstream, { status, statusText: status === 404 ? 'Not Found' : 'OK', headers }), + ); + }; + // A real Request, because the middleware derives the GET-equivalent from it. + const ctx = { request: new Request(url, { method, headers: requestHeaders }), next, forwarded }; + return ctx; +} + +describe('the landing middleware', () => { + test('reports a rewritten landing as found instead of not found', async () => { + // Measured on the deploy: Pages resolves /invite/ to the shell through + // the _redirects rewrite but keeps the not-found status of the asked path. + // Share crawlers drop a 404 before they read the rewritten meta tags. + const res = await onRequest(context({ url: 'https://realunit.app/invite/AB12CD' })); + expect(res.status).toBe(200); + // A promoted status must not keep "Not Found" as its reason phrase. + expect(res.statusText).toBe(''); + const html = await res.text(); + expect(html).toContain('RealUnit — Einladung AB12CD'); + expect(res.headers.get('content-length')).toBeNull(); + expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8'); + for (const [name, value] of Object.entries(SITE_HEADERS)) { + expect(res.headers.get(name)).toBe(value); + } + }); + + test('the media type is read as a media type, not as a substring', async () => { + // RFC 9110 makes it case-insensitive, and a parameter that merely contains + // the words is not the type. + const mixedCase = await onRequest( + context({ url: 'https://realunit.app/invite/AB12CD', type: 'Text/HTML; charset=UTF-8' }), + ); + expect(mixedCase.status).toBe(200); + expect(await mixedCase.text()).toContain('RealUnit — Einladung AB12CD'); + + // The body is the shell, so a substring match would actually rewrite it — + // with '{}' the marker guard would hand it on either way and the check + // would prove nothing. + const lookalike = await onRequest( + context({ + url: 'https://realunit.app/invite/AB12CD', + type: 'application/json; profile="text/html"', + }), + ); + expect(lookalike.status).toBe(404); + expect(await lookalike.text()).toBe(SHELL); + expect(lookalike.headers.get('content-type')).toBe('application/json; profile="text/html"'); + }); + + test('a body in another encoding is handed on byte for byte', async () => { + // Real ISO-8859-1 bytes, not a JS string: 0xE9 is 'é' there and is not + // valid UTF-8 at all. The body carries both landing marks, so only the + // charset guard stands between it and the rewrite — without it, + // response.text() would turn that byte into U+FFFD and the answer would go + // out as different bytes under a UTF-8 label. + const latin1 = new Uint8Array([ + ...new TextEncoder().encode('
'), + 0xe9, + ...new TextEncoder().encode('
'), + ]); + const ctx = context({ url: 'https://realunit.app/invite/AB12CD' }); + ctx.next = () => + Promise.resolve( + new Response(latin1, { + status: 404, + statusText: 'Not Found', + headers: new Headers({ 'content-type': 'text/html; charset=iso-8859-1' }), + }), + ); + const res = await onRequest(ctx); + expect(res.status).toBe(404); + expect(res.headers.get('content-type')).toBe('text/html; charset=iso-8859-1'); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(latin1); + }); + + test('the charset is read whatever shape it comes in', async () => { + // A rewritten answer is promoted, declares UTF-8 and carries the injected + // title; an untouched one keeps the origin's status, type and bytes. + const rewritten = async (type) => { + const res = await onRequest(context({ url: 'https://realunit.app/invite/AB12CD', type })); + const html = await res.text(); + if (res.status === 200) { + expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8'); + expect(html).toContain('RealUnit — Einladung AB12CD'); + return true; + } + expect(res.status).toBe(404); + expect(res.headers.get('content-type')).toBe(type); + expect(html).toBe(SHELL); + return false; + }; + // Quoted, unusually spelled and differently cased UTF-8 all still count — + // the parameter name is case-insensitive too. + expect(await rewritten('text/html; charset="utf-8"')).toBe(true); + expect(await rewritten('Text/HTML;Charset="utf-8"')).toBe(true); + expect(await rewritten('text/html; CHARSET=iso-8859-1')).toBe(false); + expect(await rewritten('text/html; charset=UTF8')).toBe(true); + expect(await rewritten('text/html;charset=utf-8')).toBe(true); + // Anything else is handed on rather than decoded as UTF-8 and relabelled. + expect(await rewritten('text/html; charset=utf-16')).toBe(false); + expect(await rewritten('text/html; charset=windows-1252')).toBe(false); + expect(await rewritten('text/html; charset="iso-8859-1"')).toBe(false); + // A semicolon inside a quoted value is not a parameter separator, so the + // charset that counts is the real one behind it. + expect(await rewritten('text/html; foo="x;charset=utf-8"; charset=iso-8859-1')).toBe(false); + expect(await rewritten('text/html; foo="x;charset=iso-8859-1"; charset=utf-8')).toBe(true); + // Two that disagree means hands off rather than picking one, whichever way + // round they come: neither the first nor the last wins. + expect(await rewritten('text/html; charset=utf-8; charset=iso-8859-1')).toBe(false); + // An unterminated quote is not a quoted value, so the raw text is compared + // and does not read as UTF-8. The trailing X makes the case tell the two + // apart: stripping quotes without checking for a closing one would leave + // exactly `utf-8` behind and wrongly accept it. + expect(await rewritten('text/html; charset="utf-8X')).toBe(false); + expect(await rewritten('text/html; charset=iso-8859-1; charset=utf-8')).toBe(false); + // A backslash escapes the next character inside a quoted value, so the + // closing quote here is part of the value and the real charset follows. + expect(await rewritten('text/html; foo="a\\";charset=utf-8"; charset=iso-8859-1')).toBe(false); + expect(await rewritten('text/html; foo="a\\";charset=iso-8859-1"; charset=utf-8')).toBe(true); + }); + + test('a rewritten answer says UTF-8 even when the origin left it out', async () => { + // The body was read as UTF-8 and goes out as UTF-8, so the answer says so + // rather than leaving the client to guess. + const res = await onRequest( + context({ url: 'https://realunit.app/invite/AB12CD', type: 'text/html' }), + ); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8'); + }); + + test('a promo landing is promoted the same way', async () => { + const res = await onRequest( + context({ url: 'https://realunit.app/promo/EVT1', body: PROMO_SHELL }), + ); + expect(res.status).toBe(200); + expect(res.statusText).toBe(''); + expect(await res.text()).toContain('RealUnit — Promo-Code EVT1'); + }); + + test('a refusal on a landing path is passed on, not promoted', async () => { + // Only the 404 Pages produces for these paths is an artefact. A 403 or a + // 410 means what it says, even when the body happens to be the shell. + for (const status of [403, 410]) { + // The encoding has to be there for its absence afterwards to mean + // anything — asserting against a header the fixture never set is no test. + const ctx = context({ url: 'https://realunit.app/invite/AB12CD', status }); + ctx.next = () => + Promise.resolve( + new Response(SHELL, { + status, + statusText: 'OK', + headers: new Headers({ + 'content-type': 'text/html; charset=utf-8', + 'content-length': String(SHELL_BYTES), + 'content-encoding': 'gzip', + ...SITE_HEADERS, + }), + }), + ); + const res = await onRequest(ctx); + expect(res.status).toBe(status); + expect(res.statusText).toBe('OK'); + // The rewrite still runs; what must not change is the status. And since + // it runs, the headers it invalidates have to go here as well — a + // content-length from before the rewrite would contradict the body. + expect(await res.text()).toContain('RealUnit — Einladung AB12CD'); + expect(res.headers.get('content-length')).toBeNull(); + expect(res.headers.get('content-encoding')).toBeNull(); + } + }); + + test('conditional headers are stripped from the GET-equivalent', async () => { + // Range would allow a 206, the conditional headers a 304 or a 412. None of + // those bodies is the landing shell, and the status is decided from the + // body. + const ctx = context({ + url: 'https://realunit.app/invite/AB12CD', + requestHeaders: { + 'if-none-match': 'W/"abc"', + 'if-modified-since': 'Tue, 09 Sep 2026 00:00:00 GMT', + 'if-match': 'W/"abc"', + 'if-unmodified-since': 'Tue, 09 Sep 2026 00:00:00 GMT', + 'accept-language': 'de-CH', + }, + }); + const res = await onRequest(ctx); + const [forwarded] = ctx.forwarded; + for (const name of ['if-none-match', 'if-modified-since', 'if-match', 'if-unmodified-since']) { + expect(forwarded.headers.get(name)).toBeNull(); + } + expect(forwarded.headers.get('accept-language')).toBe('de-CH'); + // And the answer that comes back is the whole document, not a 304. + expect(res.status).toBe(200); + expect(await res.text()).toContain('RealUnit — Einladung AB12CD'); + }); + + test('a GET that carries a body is still answered, not thrown on', async () => { + // The Request constructor refuses to pair a body with GET, so re-methoding + // such a request throws. It is rebuilt from the URL instead, which drops + // the platform's request metadata but keeps the request answerable. + const ctx = context({ url: 'https://realunit.app/invite/AB12CD' }); + const withBody = new Request('https://realunit.app/invite/AB12CD', { + method: 'POST', + body: 'not what a GET should carry', + }); + withBody.headers.set('range', 'bytes=0-99'); + withBody.headers.set('content-length', '27'); + withBody.headers.set('if-none-match', 'W/"abc"'); + withBody.headers.set('accept-language', 'de-CH'); + Object.defineProperty(withBody, 'method', { value: 'GET' }); + ctx.request = withBody; + const res = await onRequest(ctx); + const [forwarded] = ctx.forwarded; + expect(forwarded.method).toBe('GET'); + expect(forwarded.url).toBe('https://realunit.app/invite/AB12CD'); + // The fallback has to carry the cleaned headers, not the original ones. + expect(forwarded.headers.get('range')).toBeNull(); + expect(forwarded.headers.get('if-none-match')).toBeNull(); + expect(forwarded.headers.get('accept-language')).toBe('de-CH'); + // The rebuilt request carries no body, so it must not announce one. + expect(forwarded.headers.get('content-length')).toBeNull(); + expect(forwarded.headers.get('content-type')).toBeNull(); + expect(res.status).toBe(200); + expect(await res.text()).toContain('RealUnit — Einladung AB12CD'); + }); + + test('a status that must not carry a body is passed on instead of throwing', async () => { + // Pairing 204, 205 or 304 with a body throws, which would turn such an + // answer into a 500. + for (const status of [204, 205, 304]) { + const ctx = context({ url: 'https://realunit.app/invite/AB12CD', status }); + let upstream; + ctx.next = () => { + upstream = new Response(null, { + status, + headers: new Headers({ + 'content-type': 'text/html; charset=utf-8', + etag: 'W/"the-one-it-was-matched-on"', + ...SITE_HEADERS, + }), + }); + return Promise.resolve(upstream); + }; + const res = await onRequest(ctx); + // The origin's own answer, like the other pass-through branches. + expect(res).toBe(upstream); + expect(res.status).toBe(status); + expect(res.body).toBeNull(); + // A 304 has to keep the validator it was matched on, and none of these + // carries a representation this pass could rewrite. + expect(res.headers.get('etag')).toBe('W/"the-one-it-was-matched-on"'); + } + }); + + test('the length, encoding, validators and range are dropped after a rewrite', async () => { + // A stale validator is worse than none: a conditional request would be + // answered 304 against a document the client never received. + const ctx = context({ url: 'https://realunit.app/invite/AB12CD' }); + ctx.next = () => { + const res = new Response(SHELL, { + status: 404, + statusText: 'Not Found', + headers: new Headers({ + 'content-type': 'text/html; charset=utf-8', + 'content-length': String(SHELL_BYTES), + etag: 'W/"before-the-rewrite"', + 'last-modified': 'Tue, 09 Sep 2026 00:00:00 GMT', + 'content-range': 'bytes 0-99/4162', + 'accept-ranges': 'bytes', + // The body left here decoded, so a carried-over encoding would be + // wrong whatever the origin declared. + 'content-encoding': 'gzip', + // Not observed on this deploy, dropped for the same reason as the + // validators: they describe bytes this pass replaces. + 'content-digest': 'sha-256=:before:', + 'repr-digest': 'sha-256=:before:', + digest: 'sha-256=before', + 'content-md5': 'before', + ...SITE_HEADERS, + }), + }); + return Promise.resolve(res); + }; + const res = await onRequest(ctx); + expect(res.status).toBe(200); + for (const stale of [ + 'content-length', + 'content-encoding', + 'etag', + 'last-modified', + 'content-range', + 'accept-ranges', + 'content-digest', + 'repr-digest', + 'digest', + 'content-md5', + ]) { + expect(res.headers.get(stale)).toBeNull(); + } + // The ones the rewrite does not invalidate stay. + for (const [name, value] of Object.entries(SITE_HEADERS)) { + expect(res.headers.get(name)).toBe(value); + } + }); + + test('a partial answer is passed on instead of being rewritten', async () => { + // The body is a fragment, so injecting into it and dropping the range + // metadata would produce a 206 that describes nothing. + let partialUpstream; + const partial = (method) => { + const ctx = context({ url: 'https://realunit.app/invite/AB12CD', status: 206, method }); + // The realistic case: a partial view of the landing page itself. A body + // that is not the shell would be handed on by the marker guard anyway, + // and would not prove this guard does anything. + ctx.next = () => { + partialUpstream = new Response(SHELL, { + status: 206, + statusText: 'Partial Content', + headers: new Headers({ + 'content-type': 'text/html; charset=utf-8', + // Real byte counts: .length counts UTF-16 units, and a range has + // to describe bytes of something that exists. + 'content-length': String(SHELL_BYTES), + 'content-range': `bytes 0-${SHELL_BYTES - 1}/${SHELL_BYTES}`, + etag: 'W/"the-whole-thing"', + ...SITE_HEADERS, + }), + }); + return Promise.resolve(partialUpstream); + }; + return onRequest(ctx); + }; + + const res = await partial('GET'); + // The origin's own answer on the GET path, as on the other branches. + expect(res).toBe(partialUpstream); + expect(res.status).toBe(206); + // Handed on as it came: not rewritten, though the body is the shell. + expect(await res.text()).toBe(SHELL); + expect(res.headers.get('content-length')).toBe(String(SHELL_BYTES)); + // Nothing was rewritten, so the range metadata and the validator still + // describe what the origin sent. + expect(res.headers.get('content-range')).toBe(`bytes 0-${SHELL_BYTES - 1}/${SHELL_BYTES}`); + expect(res.headers.get('etag')).toBe('W/"the-whole-thing"'); + + // A HEAD on the same answer keeps the status and the headers, and drops + // only the body. + const head = await partial('HEAD'); + expect(head.status).toBe(206); + expect(head.statusText).toBe('Partial Content'); + expect(head.body).toBeNull(); + expect(head.headers.get('content-length')).toBe(String(SHELL_BYTES)); + expect(head.headers.get('content-range')).toBe(`bytes 0-${SHELL_BYTES - 1}/${SHELL_BYTES}`); + expect(head.headers.get('etag')).toBe('W/"the-whole-thing"'); + }); + + // The origin's own answer for the two cases below. The GET case can require + // that this very object comes back, which no look-alike would satisfy; the + // HEAD case cannot, because a body-less answer has to be a new object, so it + // compares the whole status and header set instead. + function notFoundUpstream(method) { + const ctx = context({ url: 'https://realunit.app/invite/AB12CD', method }); + let upstream; + ctx.next = (request) => { + // The recorder the default next() provides, kept: the HEAD case has to + // show that a GET-equivalent was asked for. + ctx.forwarded.push(request || ctx.request); + upstream = new Response(NOT_FOUND_PAGE, { + status: 404, + statusText: 'Not Found', + headers: new Headers({ + 'content-type': 'text/html; charset=utf-8', + 'content-length': String(NOT_FOUND_BYTES), + 'content-encoding': 'gzip', + etag: 'W/"the-404-page"', + ...SITE_HEADERS, + }), + }); + return Promise.resolve(upstream); + }; + return { ctx, sent: () => upstream }; + } + + test('a real 404 page on a landing path keeps saying 404', async () => { + // A broken deploy has to stay visibly broken rather than look healthy. + const { ctx, sent } = notFoundUpstream('GET'); + const res = await onRequest(ctx); + expect(res.status).toBe(404); + // Untouched status keeps its reason phrase. + expect(res.statusText).toBe('Not Found'); + // Handed on as the origin's own answer, not rebuilt from its text: that is + // what keeps the encoding and the validator describing the actual bytes. + expect(res).toBe(sent()); + expect(res.headers.get('content-encoding')).toBe('gzip'); + expect(res.headers.get('etag')).toBe('W/"the-404-page"'); + // And an untouched page keeps its own copy: rewriting the 404 page's title + // into an invitation would misdescribe what the visitor is looking at. + expect(await res.text()).toBe(NOT_FOUND_PAGE); + }); + + test('a landing that was already found keeps its status and is still rewritten', async () => { + // The promotion is not the only thing this pass does. An answer that + // already said 200 still gets its tags written, so a change that handed + // those through untouched has somewhere to fail. + const res = await onRequest(context({ url: 'https://realunit.app/invite/', status: 200 })); + expect(res.status).toBe(200); + expect(res.statusText).toBe('OK'); + expect(res.headers.get('content-length')).toBeNull(); + // Measured: on this codeless landing the rewrite normalises the canonical + // and the two URL tags to the form without a trailing slash. The shell + // ships them with one, so this holds only if the rewrite actually ran. + const html = await res.text(); + expect(html).toContain('rel="canonical" href="https://realunit.app/invite"'); + expect(html).toContain('property="og:url" content="https://realunit.app/invite"'); + expect(html).toContain('name="twitter:url" content="https://realunit.app/invite"'); + }); + + test('a path the rewrite does not own is passed through untouched', async () => { + const ctx = context({ url: 'https://realunit.app/', status: 200 }); + let upstream; + const inner = ctx.next; + ctx.next = async (request) => { + upstream = await inner(request); + return upstream; + }; + const res = await onRequest(ctx); + // The origin's own answer, as on every other pass-through branch. + expect(res).toBe(upstream); + expect(await res.text()).toBe(SHELL); + // Passed through, so the header the rewrite would have dropped is still there. + expect(res.headers.get('content-length')).toBe(String(SHELL_BYTES)); + }); + + test('HEAD answers like GET by asking for the GET-equivalent', async () => { + // A link checker sends HEAD first. A HEAD response has no body, and the + // body is what tells the landing shell from the site's 404 page — so the + // status has to come from a GET-equivalent lookup, or the same link would + // read as found by GET and as dead by HEAD. + const ctx = context({ url: 'https://realunit.app/invite/AB12CD', method: 'HEAD' }); + const head = await onRequest(ctx); + const get = await onRequest(context({ url: 'https://realunit.app/invite/AB12CD' })); + expect(ctx.forwarded.map((r) => r.method)).toEqual(['GET']); + expect(head.status).toBe(get.status); + expect(head.status).toBe(200); + expect(head.statusText).toBe(''); + // No body at all, not an empty one: `new Response('')` would still carry a + // stream, and text() cannot tell the two apart. + expect(head.body).toBeNull(); + expect(await head.text()).toBe(''); + for (const name of ['content-type', ...Object.keys(SITE_HEADERS)]) { + expect(head.headers.get(name)).toBe(get.headers.get(name)); + } + expect(head.headers.get('content-length')).toBeNull(); + }); + + test('a plain GET with Range is forwarded without it', async () => { + // This pass rewrites the whole document, so a partial representation is + // never useful: it would be injected into a fragment and returned under a + // Content-Range describing the bytes before the rewrite. + const ctx = context({ + url: 'https://realunit.app/invite/AB12CD', + requestHeaders: { range: 'bytes=0-99', 'if-range': 'W/"abc"' }, + }); + const res = await onRequest(ctx); + const [forwarded] = ctx.forwarded; + expect(forwarded.method).toBe('GET'); + expect(forwarded.headers.get('range')).toBeNull(); + expect(forwarded.headers.get('if-range')).toBeNull(); + expect(res.status).toBe(200); + expect(await res.text()).toContain('RealUnit — Einladung AB12CD'); + }); + + test('the GET-equivalent drops Range and keeps the unrelated headers', async () => { + // A HEAD carrying Range would otherwise come back as 206, whose body is not + // the landing shell — the status would neither be promoted nor mean what + // the client asked for. Headers unrelated to choosing the representation + // are kept; the conditional ones are covered by their own case above. + const ctx = context({ + url: 'https://realunit.app/invite/AB12CD', + method: 'HEAD', + requestHeaders: { + range: 'bytes=0-99', + 'if-range': 'W/"abc"', + 'accept-language': 'en-GB', + 'user-agent': 'link-checker/1.0', + }, + }); + const res = await onRequest(ctx); + const [forwarded] = ctx.forwarded; + expect(forwarded.method).toBe('GET'); + expect(forwarded.headers.get('range')).toBeNull(); + expect(forwarded.headers.get('if-range')).toBeNull(); + expect(forwarded.headers.get('accept-language')).toBe('en-GB'); + expect(forwarded.headers.get('user-agent')).toBe('link-checker/1.0'); + expect(forwarded.url).toBe(ctx.request.url); + // And the answer is the whole document, not a partial one: stripping the + // header would mean little if the response were still treated as a 206. + expect(res.status).toBe(200); + expect(res.statusText).toBe(''); + expect(res.body).toBeNull(); + }); + + test('a HEAD on a real 404 page keeps saying 404', async () => { + // The marker guard has to hold on the HEAD path too, not only on GET. + const { ctx, sent } = notFoundUpstream('HEAD'); + const res = await onRequest(ctx); + expect(ctx.forwarded.map((r) => r.method)).toEqual(['GET']); + expect(res.status).toBe(404); + expect(res.statusText).toBe('Not Found'); + expect(res.body).toBeNull(); + // Only the body is left off; every header the origin sent is still there. + for (const [name, value] of sent().headers) { + expect(res.headers.get(name)).toBe(value); + } + expect([...res.headers.keys()].sort()).toEqual([...sent().headers.keys()].sort()); + }); + + test('a HEAD on a response that is not HTML keeps its status and carries no body', async () => { + const ctx = context({ + url: 'https://realunit.app/invite/AB12CD', + method: 'HEAD', + type: 'application/json', + body: '{}', + }); + // Nothing was rewritten here, so every header still describes what the + // origin sent and must survive — the length included, which RFC 9110 wants + // a HEAD to carry as the GET would have. + ctx.next = () => { + const headers = new Headers({ + 'content-type': 'application/json', + 'content-length': '2', + etag: 'W/"unchanged"', + 'content-encoding': 'gzip', + ...SITE_HEADERS, + }); + return Promise.resolve(new Response('{}', { status: 404, statusText: 'Not Found', headers })); + }; + const res = await onRequest(ctx); + // Every header the origin sent survives, name for name — listing a few by + // hand would miss a regression that drops one nobody thought to name. + const sent = new Headers({ + 'content-type': 'application/json', + 'content-length': '2', + etag: 'W/"unchanged"', + 'content-encoding': 'gzip', + ...SITE_HEADERS, + }); + for (const [name, value] of sent) { + expect(res.headers.get(name)).toBe(value); + } + expect([...res.headers.keys()].sort()).toEqual([...sent.keys()].sort()); + expect(res.status).toBe(404); + expect(res.statusText).toBe('Not Found'); + expect(res.body).toBeNull(); + // The length still describes the representation a GET would have returned. + expect(res.headers.get('content-length')).toBe('2'); + // This branch rebuilds the response too, so it has to carry the headers. + for (const [name, value] of Object.entries(SITE_HEADERS)) { + expect(res.headers.get(name)).toBe(value); + } + }); + + test('a method that is neither GET nor HEAD is passed through untouched', async () => { + const ctx = context({ url: 'https://realunit.app/invite/AB12CD', method: 'POST' }); + let upstream; + ctx.next = () => { + upstream = new Response(SHELL, { + status: 404, + headers: new Headers({ + 'content-type': 'text/html; charset=utf-8', + 'content-length': String(SHELL_BYTES), + etag: 'W/"untouched"', + }), + }); + return Promise.resolve(upstream); + }; + const res = await onRequest(ctx); + // The origin's own answer, as with the other pass-through branches. + expect(res).toBe(upstream); + expect(res.status).toBe(404); + expect(res.headers.get('etag')).toBe('W/"untouched"'); + expect(await res.text()).toBe(SHELL); + }); + + test('a response that is not HTML is passed through untouched', async () => { + const ctx = context({ url: 'https://realunit.app/invite/AB12CD' }); + let upstream; + ctx.next = () => { + upstream = new Response('{}', { + status: 404, + statusText: 'Not Found', + headers: new Headers({ + 'content-type': 'application/json', + 'content-length': '2', + 'content-encoding': 'gzip', + etag: 'W/"the-json"', + ...SITE_HEADERS, + }), + }); + return Promise.resolve(upstream); + }; + const res = await onRequest(ctx); + // The origin's own answer, not one rebuilt to look like it. + expect(res).toBe(upstream); + expect(res.status).toBe(404); + expect(await res.text()).toBe('{}'); + expect(res.headers.get('content-encoding')).toBe('gzip'); + expect(res.headers.get('etag')).toBe('W/"the-json"'); + }); + + test('the marks have to be the attributes, not the words', async () => { + // A page that merely mentions state-loading is not a landing shell. A + // looser match would promote it from 404 to 200 and rewrite its title. + const mentionsIt = + 'Seite nicht gefunden' + + '

Der Abschnitt state-loading fehlt.

'; + // And the exact attribute text, but in a comment rather than an element. + // With one mark this was the boundary the check could not see; the second + // mark closes it, and the assertion below is what holds that shut. + const quotesIt = + 'Seite nicht gefunden' + + ''; + const res = await onRequest( + context({ url: 'https://realunit.app/invite/AB12CD', body: mentionsIt }), + ); + expect(res.status).toBe(404); + expect(await res.text()).toBe(mentionsIt); + + // Both words as prose, without the attribute syntax: loosening the marks to + // bare words would accept this, and nothing else in the suite would notice. + const wordsOnly = + 'Seite nicht gefunden' + + '

state-loading und aria-busy sind hier nur Wörter.

'; + const prose = await onRequest( + context({ url: 'https://realunit.app/invite/AB12CD', body: wordsOnly }), + ); + expect(prose.status).toBe(404); + expect(await prose.text()).toBe(wordsOnly); + + // Even the exact attribute in a comment is not enough on its own: the shell + // is recognised by two marks, and an error document does not arrive at both. + const quoted = await onRequest( + context({ url: 'https://realunit.app/invite/AB12CD', body: quotesIt }), + ); + expect(quoted.status).toBe(404); + expect(await quoted.text()).toBe(quotesIt); + }); + + test('the marks the promotion keys on live where they have to', () => { + // The contract the promotion tests rely on, asserted against the shipped + // files rather than assumed. + for (const mark of ['id="state-loading"', 'aria-busy="true"']) { + expect(SHELL).toContain(mark); + expect(PROMO_SHELL).toContain(mark); + expect(NOT_FOUND_PAGE).not.toContain(mark); + } + }); + + test('of everything the site ships, only the two landings read as a shell', () => { + // The check is a substring test: it does not require the two marks to + // share an element, or to be in an element at all. What keeps that from + // mattering is this property, and it is worth asserting rather than + // describing — of every page the site ships, only the two the Function is + // routed to reach both marks. The account-merge and Aktionariat shells + // carry the id and no aria-busy; the 404 page carries neither. This goes + // red the day one of them gains the second mark, which is the day the + // guard would have to become a real parse. + // The inventory first, so that a walker which quietly stopped finding the + // other pages could not make the second assertion pass by having nothing + // left to disagree with. + const files = shippedHtml().sort(); + expect(files).toEqual([ + '404.html', + 'account-merge/index.html', + 'confirm-aktionariat/index.html', + 'index.html', + 'invite/index.html', + 'promo/index.html', + ]); + expect(files.filter((file) => isLandingShell(page(file)))).toEqual([ + 'invite/index.html', + 'promo/index.html', + ]); + }); + + test('a response with no content-type is passed through untouched', async () => { + // Constructing a Response from a string sets content-type on its own, so + // the header is removed again to reach the missing-header path. + const ctx = context({ url: 'https://realunit.app/invite/AB12CD' }); + let upstream; + ctx.next = () => { + upstream = new Response(SHELL, { status: 404 }); + upstream.headers.delete('content-type'); + upstream.headers.set('etag', 'W/"no-type"'); + return Promise.resolve(upstream); + }; + const res = await onRequest(ctx); + // The origin's own answer again, not a look-alike. + expect(res).toBe(upstream); + expect(res.headers.get('content-type')).toBeNull(); + expect(res.headers.get('etag')).toBe('W/"no-type"'); + expect(res.status).toBe(404); + expect(await res.text()).toBe(SHELL); + }); +}); 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 + //