// Builds the site and checks what search engines, link previews and visitors get from it, in every // language the site is published in: every page has its own title and description, a canonical // address, hreflang alternates that answer each other, Open Graph and Twitter tags, valid JSON-LD and // exactly one h1; the sitemap lists every page of every language and nothing that is not in the // build; every catalog item has its page; no link or picture on any page points at something // missing; a translated page links only to pages of its own language; and no page is half // translated (every catalog has every key, and no English sentence is left on a translated page). // Part of `bun run test`, so a catalog or a translation change that breaks a page fails before it ships. import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, posix, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { build } from 'vite'; import { checkCatalogs, loadI18nConfig } from '../../../waybetter/packages/waybetter/cli/i18n.mjs'; import { CODE_PREFIXES, extractPages, languages, loadCatalogs } from '../i18n.ts'; import { DESCRIPTION_MAX, DESCRIPTION_MIN, TITLE_MAX, clip, itemPath, itemSlugs, langPath, type Catalog } from '../seo.ts'; import { LANGUAGES, MIN_APP_VERSION, SITE_URL } from '../site.config.ts'; import { latestItems } from '../store-pages.ts'; import { areaItems, areaPath } from '../areas.ts'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const catalog = JSON.parse(readFileSync(join(ROOT, 'public/store/catalog.json'), 'utf8')) as Catalog; const LANGS = languages(ROOT); const CODES = LANGS.map(language => language.code); const OTHER = CODES.filter(code => code !== 'en'); /** Every test reads every page of every language: generous on a busy machine. */ const SLOW = 120_000; const BASES = ['/', '/x/', '/store/', '/store/submit/', '/themes/', '/plugins/']; /** The area plugins' pages (built into the app, not catalog items). */ const AREA_PATHS = areaItems().map(area => areaPath(area.id)); let out = ''; type Page = { file: string; path: string; lang: string; base: string; html: string }; let pages: Page[] = []; function htmlFiles(dir: string): string[] { return readdirSync(dir).flatMap(name => { const path = join(dir, name); return statSync(path).isDirectory() ? htmlFiles(path) : name.endsWith('.html') ? [path] : []; }, SLOW); } const decode = (value: string) => value.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, "'").replace(/ /g, '\u00a0').replace(/&/g, '&'); const attr = (html: string, pattern: RegExp) => { const value = pattern.exec(html)?.[1]; return value === undefined ? undefined : decode(value); }; const meta = (html: string, key: string) => attr(html, new RegExp(` attr(html, /([^<]*)<\/title>/); const canonical = (html: string) => attr(html, /<link rel="canonical" href="([^"]*)"/); const alternates = (html: string) => new Map([...html.matchAll(/<link rel="alternate" hreflang="([^"]+)" href="([^"]+)"/g)].map(match => [match[1]!, match[2]!])); const isNotFound = (page: Page) => page.base === '/404.html'; const known = new Map<string, boolean>(); /** Whether `file` is a file of the build (remembered: every page asks for the same few hundred). */ function isFile(file: string): boolean { let answer = known.get(file); if (answer === undefined) known.set(file, answer = existsSync(file) && !statSync(file).isDirectory()); return answer; } const pageAt = (path: string) => pages.find(page => page.path === path)!; const inLanguage = (lang: string) => pages.filter(page => page.lang === lang); /** What a visitor reads: no scripts, styles or code, no tags, and without what is marked as not this page's language. */ function visibleText(html: string): string { let body = html.slice(html.indexOf('<body')); body = body.replace(/<(script|style|pre|code|kbd)\b[\s\S]*?<\/\1>/g, ' '); // An item's own words stay English (lang="en"), and brand marks, commands and names are not translated. for (let again = true; again;) { const next = body.replace(/<([a-z0-9]+)\b[^>]*\s(?:lang="en"|translate="no")[^>]*>(?:(?!<\1\b)[\s\S])*?<\/\1>/g, ' '); again = next !== body; body = next; } const attrs = [...body.matchAll(/\s(?:aria-label|alt|placeholder|title)="([^"]*)"/g)].map(match => match[1]!).join(' \n '); return decode(`${body.replace(/<[^>]+>/g, ' ')} \n ${attrs}`).replace(/\s+/g, ' '); } /** Whether `text` has `words` as whole words ("için wayBetter" does not have "in wayBetter"). */ function hasWords(text: string, words: string): boolean { for (let at = text.indexOf(words); at >= 0; at = text.indexOf(words, at + 1)) { const before = text[at - 1] ?? ' '; const after = text[at + words.length] ?? ' '; if (!/[\p{L}\p{N}]/u.test(before) && !/[\p{L}\p{N}]/u.test(after)) return true; } return false; } /** The literal text of a message: what is left when the placeholders and the plural/select syntax are taken out. */ const literals = (message: string): string[] => message.replace(/\{[^{}]*\{[^{}]*\}[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g, '\u0000').split(/\{[^{}]*\}|\u0000/).map(part => part.replace(/\s+/g, ' ').trim()); beforeAll(async () => { out = mkdtempSync(join(tmpdir(), 'wb-site-')); await build({ root: ROOT, configFile: join(ROOT, 'vite.config.ts'), logLevel: 'silent', build: { outDir: out, emptyOutDir: true } }); pages = htmlFiles(out).map(file => { const path = `/${relative(out, file).split('\\').join('/').replace(/index\.html$/, '')}`; const first = path.split('/')[1]!; const lang = OTHER.includes(first) ? first : 'en'; return { file, path, lang, base: lang === 'en' ? path : path.slice(lang.length + 1), html: readFileSync(file, 'utf8') }; }, SLOW); }, 600_000); afterAll(() => { if (out) rmSync(out, { recursive: true, force: true }); }); describe('the catalogs', () => { test('en.json is what the pages say, and every language has every key (no mixed-language pages)', () => { const all = loadCatalogs(ROOT); const english = all.get('en')!.messages; const fromPages = extractPages(ROOT).messages; const byHand = (key: string) => CODE_PREFIXES.some(prefix => key.startsWith(prefix)); // `node scripts/i18n-site.mjs --write` regenerates the pages' part of en.json. expect(Object.fromEntries(Object.entries(english).filter(([key]) => !byHand(key)))).toEqual(fromPages); for (const [code, entry] of all) { const missing = Object.keys(english).filter(key => !(key in entry.messages)); expect([code, missing]).toEqual([code, []]); const unknown = Object.keys(entry.messages).filter(key => !(key in english)); expect([code, unknown]).toEqual([code, []]); } }, SLOW); test('the framework\'s catalog check finds no errors (syntax, placeholders, markup, length limits)', async () => { const result = await checkCatalogs(loadI18nConfig(ROOT, 'waybetter.i18n.json')); expect(result.lines.filter((line: string) => line.startsWith('ERROR'))).toEqual([]); expect(result.errors).toBe(0); }, SLOW); test('every site language has a catalog, and the API serves the same list', () => { expect(CODES).toEqual([...LANGUAGES]); const api = readFileSync(join(ROOT, '../api/src/app.ts'), 'utf8'); const listed = /export const SITE_LANGUAGES = (\[[^\]]*\])/.exec(api)![1]!; expect([...listed.matchAll(/'([^']+)'/g)].map(match => match[1]!).sort()).toEqual(OTHER.slice().sort()); }, SLOW); test('the minimum app version is one constant: no catalog spells a version, and the pages show the constant', () => { for (const [code, entry] of loadCatalogs(ROOT)) { const spelled = Object.entries(entry.messages).filter(([, value]) => /\b\d+\.\d+\.\d+\b/.test(value)).map(([key]) => key); expect([code, spelled]).toEqual([code, []]); } expect(MIN_APP_VERSION).toBe('1.0.0'); for (const lang of CODES) { const store = pageAt(langPath(lang, '/store/')); expect([lang, store.html.includes(MIN_APP_VERSION)]).toEqual([lang, true]); for (const page of inLanguage(lang)) { for (const match of page.html.matchAll(/data-install-since="([^"]*)"/g)) expect([page.path, match[1]]).toEqual([page.path, MIN_APP_VERSION]); } } }, SLOW); test('every key the scripts look up is in en.json, and every script message is looked up', () => { const english = loadCatalogs(ROOT).get('en')!.messages; const used = new Set<string>(); for (const file of readdirSync(join(ROOT, 'src')).filter(name => name.endsWith('.ts'))) { for (const match of readFileSync(join(ROOT, 'src', file), 'utf8').matchAll(/\bt\('(site\.js\.[\w.]+)'/g)) used.add(match[1]!); } for (const key of used) expect([key, key in english]).toEqual([key, true]); for (const key of Object.keys(english).filter(name => name.startsWith('site.js.'))) expect([key, used.has(key)]).toEqual([key, true]); }, SLOW); }); describe('every built page', () => { test('there is a page for the home, the app, the Store, the submit page, both galleries and every item, in every language', () => { const paths = new Set(pages.map(page => page.path)); const slugs = itemSlugs(catalog.items); for (const code of CODES) { for (const base of [...BASES, '/404.html']) expect([code, base, paths.has(langPath(code, base))]).toEqual([code, base, true]); for (const item of catalog.items) expect(paths.has(langPath(code, itemPath(item, slugs.get(item)!)))).toBe(true); for (const path of AREA_PATHS) expect([code, path, paths.has(langPath(code, path))]).toEqual([code, path, true]); } expect(pages.length).toBe((7 + catalog.items.length + AREA_PATHS.length) * CODES.length); }, SLOW); test('says its language and direction', () => { for (const page of pages) { const tag = /<html[^>]*>/.exec(page.html)![0]; const language = LANGS.find(entry => entry.code === page.lang)!; expect([page.path, /lang="([^"]+)"/.exec(tag)?.[1]]).toEqual([page.path, page.lang]); // English pages say nothing about direction; every translated page says its own. expect([page.path, /dir="([^"]+)"/.exec(tag)?.[1] ?? 'ltr']).toEqual([page.path, language.dir]); if (page.lang !== 'en') expect([page.path, /dir="/.test(tag)]).toEqual([page.path, true]); } }, SLOW); test('has a title, a description, a canonical address and exactly one h1', () => { for (const page of pages) { const where = page.path; const name = title(page.html) ?? ''; const description = meta(page.html, 'description') ?? ''; expect([where, name, name.length > 0 && [...name].length <= TITLE_MAX]).toEqual([where, name, true]); expect([where, description, [...description].length >= DESCRIPTION_MIN]).toEqual([where, description, true]); // Pages generated from the catalog stay inside what a search result shows. if (page.base.startsWith('/store/') || page.base === '/themes/' || page.base === '/plugins/') expect([where, description, [...description].length <= DESCRIPTION_MAX]).toEqual([where, description, true]); expect([where, (page.html.match(/<h1[\s>]/g) ?? []).length]).toEqual([where, 1]); // No markers, tokens or translation marks left behind by the generator. expect([where, /<!-- (seo|store:|repo:|lang:)|%[A-Z_]+(:[^%]*)?%|\sdata-i18n/.exec(page.html)?.[0] ?? null]).toEqual([where, null]); if (isNotFound(page)) continue; expect([where, canonical(page.html)]).toEqual([where, `${SITE_URL}${page.path}`]); } }, SLOW); test('titles and descriptions are unique in each language, canonical addresses everywhere', () => { for (const code of CODES) { for (const read of [(page: Page) => title(page.html), (page: Page) => meta(page.html, 'description')]) { const seen = new Map<string, string>(); for (const page of inLanguage(code)) { const value = read(page)!; expect([page.path, value, seen.get(value) ?? null]).toEqual([page.path, value, null]); seen.set(value, page.path); } } } const addresses = pages.map(page => canonical(page.html) ?? page.path); expect(new Set(addresses).size).toBe(addresses.length); }, SLOW); test('a translated page has its own title and description, not the English ones', () => { for (const base of [...BASES, '/404.html']) { const english = pageAt(base); for (const code of OTHER) { const page = pageAt(langPath(code, base)); // "Plugins — wayBetter for X" reads the same in the languages that say "Plugins". if (base !== '/themes/' && base !== '/plugins/') expect([page.path, title(page.html) === title(english.html)]).toEqual([page.path, false]); expect([page.path, meta(page.html, 'description') === meta(english.html, 'description')]).toEqual([page.path, false]); if (!isNotFound(page)) expect([page.path, meta(page.html, 'og:description') === meta(english.html, 'og:description')]).toEqual([page.path, false]); } } }, SLOW); test('names every language of itself (hreflang), and the others name it back', () => { for (const page of pages) { const links = alternates(page.html); if (isNotFound(page)) { expect([page.path, links.size]).toEqual([page.path, 0]); continue; } // Every language and x-default, itself included; x-default is the English page. expect([page.path, [...links.keys()]]).toEqual([page.path, [...CODES, 'x-default']]); expect([page.path, links.get(page.lang)]).toEqual([page.path, canonical(page.html)!]); expect([page.path, links.get('x-default')]).toEqual([page.path, `${SITE_URL}${page.base}`]); for (const code of CODES) { const address = links.get(code)!; expect([page.path, code, address]).toEqual([page.path, code, `${SITE_URL}${langPath(code, page.base)}`]); const other = pageAt(address.slice(SITE_URL.length)); expect([page.path, code, Boolean(other)]).toEqual([page.path, code, true]); // Reciprocal: the page it names names this one for this one's language. expect([page.path, code, alternates(other.html).get(page.lang)]).toEqual([page.path, code, canonical(page.html)!]); } } }, SLOW); test('has Open Graph and Twitter card tags, with a preview image that is in the build', () => { for (const page of pages) { const where = page.path; for (const key of ['og:title', 'og:description', 'og:image', 'og:image:alt', 'og:type', 'og:site_name', 'twitter:card', 'twitter:title', 'twitter:description', 'twitter:image']) { expect([where, key, Boolean(meta(page.html, key))]).toEqual([where, key, true]); } expect([where, meta(page.html, 'twitter:card')]).toEqual([where, 'summary_large_image']); if (!isNotFound(page)) { expect([where, meta(page.html, 'og:url')]).toEqual([where, `${SITE_URL}${page.path}`]); expect([where, Boolean(meta(page.html, 'og:locale'))]).toEqual([where, true]); expect([where, (page.html.match(/property="og:locale:alternate"/g) ?? []).length]).toEqual([where, CODES.length - 1]); } for (const key of ['og:image', 'twitter:image']) { const image = meta(page.html, key)!; expect([where, image.startsWith(`${SITE_URL}/`)]).toEqual([where, true]); expect([where, image, isFile(join(out, image.slice(SITE_URL.length)))]).toEqual([where, image, true]); } } }, SLOW); test('carries valid JSON-LD: breadcrumbs, the Store\'s search action, and what each item is', () => { const data = (page: Page) => [...page.html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map(match => JSON.parse(match[1]!) as Record<string, any>); const slugs = itemSlugs(catalog.items); for (const code of CODES) { const at = (path: string) => `${SITE_URL}${langPath(code, path)}`; const store = data(pageAt(langPath(code, '/store/'))); const site = store.find(entry => entry['@type'] === 'WebSite')!; expect(site.potentialAction['@type']).toBe('SearchAction'); expect(site.potentialAction.target.urlTemplate).toBe(`${at('/store/')}?q={search_term_string}`); expect(store.find(entry => entry['@type'] === 'CollectionPage')!.mainEntity.numberOfItems).toBe(catalog.items.length); for (const item of catalog.items) { const path = itemPath(item, slugs.get(item)!); const entries = data(pageAt(langPath(code, path))); const thing = entries.find(entry => entry['@type'] === (item.kind === 'plugin' ? 'SoftwareApplication' : 'CreativeWork'))!; // An item keeps its own name in every language. expect([code, path, thing?.name, thing?.url]).toEqual([code, path, item.name, at(path)]); const crumbs = entries.find(entry => entry['@type'] === 'BreadcrumbList')!; expect([code, path, crumbs.itemListElement.at(-1).item]).toEqual([code, path, at(path)]); } } for (const page of pages) if (!isNotFound(page)) expect([page.path, data(page).length > 0]).toEqual([page.path, true]); }, SLOW); test('only the 404 pages are kept out of search results', () => { for (const page of pages) { const robots = meta(page.html, 'robots') ?? ''; expect([page.path, /noindex/.test(robots)]).toEqual([page.path, isNotFound(page)]); } }, SLOW); }); describe('languages', () => { test('no English sentence is left on a translated page', () => { const all = loadCatalogs(ROOT); const english = all.get('en')!.messages; const left: string[] = []; for (const code of OTHER) { const own = all.get(code)!.messages; // Sentences of English messages this language words differently: none may show on its pages. const sentences = new Set<string>(); for (const [key, message] of Object.entries(english)) { if (!own[key] || own[key] === message) continue; const translated = new Set(literals(own[key]!)); for (const part of literals(message)) if (part.length >= 12 && /\s/.test(part) && !translated.has(part) && !own[key]!.includes(part)) sentences.add(part); } for (const page of inLanguage(code)) { const text = visibleText(page.html); for (const sentence of sentences) if (hasWords(text, sentence)) left.push(`${page.path}: ${sentence}`); } } expect(left.slice(0, 20)).toEqual([]); }, SLOW); test('no sentence is left untranslated in a catalog', () => { // Names, numbers and words two languages share may read the same ("Plugins", "Internet", "1 tab"); // a whole sentence that is still the English one was forgotten. const english = loadCatalogs(ROOT).get('en')!.messages; const same: string[] = []; for (const [code, entry] of loadCatalogs(ROOT)) { if (code === 'en') continue; for (const [key, message] of Object.entries(entry.messages)) { const words = message.replace(/\{[^{}]*\}/g, ' ').split(/\s+/).filter(word => /\p{L}{2}/u.test(word)); if (message === english[key] && words.length >= 4) same.push(`${code} ${key}: ${message}`); } } expect(same).toEqual([]); }, SLOW); test('a translated page links only to pages of its own language', () => { const slugs = itemSlugs(catalog.items); const bases = new Set([...BASES, ...catalog.items.map(item => itemPath(item, slugs.get(item)!)), ...AREA_PATHS]); const strayed: string[] = []; for (const page of pages) { const from = isNotFound(page) ? langPath(page.lang, '/') : page.path; const body = page.html.replace(/<details class="lang[\s\S]*?<\/details>/g, ''); for (const match of body.matchAll(/<a\s[^>]*?href="([^"]*)"/g)) { const raw = decode(match[1]!); if (!raw || /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(raw)) continue; const target = posix.normalize(raw.startsWith('/') ? raw : posix.join(from.replace(/[^/]*$/, ''), raw)).split(/[?#]/)[0]!; const first = target.split('/')[1]!; const lang = OTHER.includes(first) ? first : 'en'; const base = lang === 'en' ? target : target.slice(lang.length + 1); // A page of the site (not a file, not the API): it must be this page's language. if (bases.has(base) && lang !== page.lang) strayed.push(`${page.path} → ${raw}`); // And nothing that is not a page may sit under a language's folder. if (!bases.has(base) && lang !== 'en') strayed.push(`${page.path} → ${raw} (no such page)`); } } expect(strayed).toEqual([]); }, SLOW); test('the language menu is on every page, twice, and leads to this same page in every language', () => { for (const page of pages) { const menus = [...page.html.matchAll(/<details class="lang lang-(top|foot)" data-lang-menu>([\s\S]*?)<\/details>/g)]; expect([page.path, menus.map(menu => menu[1])]).toEqual([page.path, ['top', 'foot']]); const from = isNotFound(page) ? langPath(page.lang, '/') : page.path; for (const menu of menus) { const links = [...menu[2]!.matchAll(/<a href="([^"]*)" hreflang="([^"]+)" lang="([^"]+)" dir="(ltr|rtl)" data-lang="([^"]+)"( aria-current="true")?>([^<]+)<\/a>/g)]; expect([page.path, links.map(link => link[5])]).toEqual([page.path, CODES]); for (const [, href, hreflang, lang, dir, code, current, name] of links) { const language = LANGS.find(entry => entry.code === code)!; // Each language in its own name, marked as that language. expect([page.path, code, hreflang, lang, dir, decode(name!)]).toEqual([page.path, code, code, code, language.dir, language.name]); expect([page.path, code, Boolean(current)]).toEqual([page.path, code, code === page.lang]); const target = posix.normalize(posix.join(from.replace(/[^/]*$/, ''), decode(href!))); // The 404 page answers for any address: without JavaScript its menu leads to each language's home. expect([page.path, code, target]).toEqual([page.path, code, langPath(code!, isNotFound(page) ? '/' : page.base)]); } } } }, SLOW); test('only an English page may send a first visit to the visitor\'s language, and only from a script', () => { for (const page of pages) { const script = /<script>\(function\(\)\{var L=[\s\S]*?<\/script>/.exec(page.html)?.[0] ?? ''; expect([page.path, script.includes('localStorage') && script.includes('a[data-lang]')]).toEqual([page.path, true]); expect([page.path, script.includes('navigator.languages')]).toEqual([page.path, page.lang === 'en']); // Never for crawlers, never by a header or a meta refresh: the HTML is complete without the script. if (page.lang === 'en') expect([page.path, /bot\|crawl\|spider/.test(script) && script.includes('navigator.webdriver')]).toEqual([page.path, true]); expect([page.path, /http-equiv="refresh"/i.test(page.html)]).toEqual([page.path, false]); } }, SLOW); test('an item\'s own words are marked as English on a translated page', () => { const slugs = itemSlugs(catalog.items); const escaped = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); for (const code of OTHER) { const dir = LANGS.find(entry => entry.code === code)!.dir === 'rtl' ? ' dir="ltr"' : ''; for (const item of catalog.items) { const page = pageAt(langPath(code, itemPath(item, slugs.get(item)!))); expect([page.path, page.html.includes(`<h1 lang="en"${dir}>${escaped(item.name)}</h1>`)]).toEqual([page.path, true]); expect([page.path, new RegExp(`<p class="it-desc" lang="en"${dir}>`).test(page.html)]).toEqual([page.path, true]); } const store = pageAt(langPath(code, '/store/')); expect([code, (store.html.match(new RegExp(`<p class="gal-desc" lang="en"${dir}>`, 'g')) ?? []).length >= catalog.items.length]).toEqual([code, true]); } // English pages need no mark. expect(/<(?:p|h1|li|ul|figcaption)[^>]*\slang="en"/.test(pageAt('/store/').html)).toBe(false); }, SLOW); }); describe('sitemap.xml and robots.txt', () => { const sitemap = () => readFileSync(join(out, 'sitemap.xml'), 'utf8'); const entries = () => [...sitemap().matchAll(/<url>(.*?)<\/url>/g)].map(match => match[1]!); const locations = () => entries().map(entry => /<loc>([^<]+)<\/loc>/.exec(entry)![1]!); test('the sitemap lists every page of every language once, and only pages that are in the build', () => { const urls = locations(); expect(new Set(urls).size).toBe(urls.length); const indexable = pages.filter(page => !isNotFound(page)).map(page => `${SITE_URL}${page.path}`); expect([...urls].sort()).toEqual([...indexable].sort()); for (const url of urls) { const path = url.slice(SITE_URL.length); expect([url, isFile(join(out, path, path.endsWith('/') ? 'index.html' : ''))]).toEqual([url, true]); } }, SLOW); test('every entry has a last-modified date and the same alternates as its page', () => { expect(sitemap()).toContain('xmlns:xhtml="http://www.w3.org/1999/xhtml"'); for (const entry of entries()) { expect([entry.slice(0, 80), /<lastmod>\d{4}-\d{2}-\d{2}<\/lastmod>/.test(entry)]).toEqual([entry.slice(0, 80), true]); const url = /<loc>([^<]+)<\/loc>/.exec(entry)![1]!; const links = new Map([...entry.matchAll(/<xhtml:link rel="alternate" hreflang="([^"]+)" href="([^"]+)"\/>/g)].map(match => [match[1]!, match[2]!])); expect([url, [...links]]).toEqual([url, [...alternates(pageAt(url.slice(SITE_URL.length)).html)]]); } }, SLOW); test('robots.txt allows crawling and points at the sitemap', () => { const robots = readFileSync(join(out, 'robots.txt'), 'utf8'); expect(robots).toBe(`User-agent: *\nAllow: /\nDisallow: /v1/\nDisallow: /health\n\nSitemap: ${SITE_URL}/sitemap.xml\n`); }, SLOW); }); describe('links and files', () => { test('the files the apps download are in the build byte for byte', () => { const same = (path: string) => readFileSync(join(out, 'store', path)).equals(readFileSync(join(ROOT, 'public/store', path))); expect(same('catalog.json')).toBe(true); expect(same('catalog.json.sig')).toBe(true); for (const item of catalog.items) { expect([item.id, same(item.url.slice(`${SITE_URL}/store/`.length))]).toEqual([item.id, true]); if (item.preview) expect([item.id, same(item.preview.slice(`${SITE_URL}/store/`.length))]).toEqual([item.id, true]); } }, SLOW); test('no page links to, or shows, something that is not in the build', () => { const missing: string[] = []; for (const page of pages) { // A 404 page is served at any depth and resolves its links from its language's home (<base href>). const from = isNotFound(page) ? langPath(page.lang, '/') : page.path; if (isNotFound(page)) expect([page.path, /<base href="([^"]+)"/.exec(page.html)?.[1]]).toEqual([page.path, from]); for (const match of page.html.matchAll(/\s(?:href|src|poster|data-src|data-poster-small|data-install-file|data-install-app-page|data-source-url)="([^"]*)"/g)) { const raw = match[1]!.replace(/&/g, '&'); if (!raw || /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(raw)) continue; // other sites, waybetter://, mailto:, in-page anchors const target = posix.normalize(raw.startsWith('/') ? raw : posix.join(from.replace(/[^/]*$/, ''), raw)).split(/[?#]/)[0]!; if (target.startsWith('/v1/')) continue; // the API (download redirect), not a file if (!isFile(join(out, target, target.endsWith('/') ? 'index.html' : ''))) missing.push(`${page.path} → ${raw}`); } // In-page anchors point at something on the page. for (const match of page.html.matchAll(/\shref="#([^"]+)"/g)) if (!new RegExp(`\\sid="${match[1]}"`).test(page.html)) missing.push(`${page.path} → #${match[1]}`); } expect(missing).toEqual([]); }, SLOW); test('every install button carries its fallback: the file to download and the app to get', () => { let buttons = 0; for (const page of pages) { for (const match of page.html.matchAll(/<a [^>]*href="waybetter:\/\/install\?[^>]*>/g)) { buttons += 1; const tag = match[0]; for (const key of ['data-install', 'data-install-name=', 'data-install-file=', 'data-install-filename=', 'data-install-app=', 'data-install-app-page=', 'data-install-since=']) expect([page.path, key, tag.includes(key)]).toEqual([page.path, key, true]); expect([page.path, /href="waybetter:\/\/install\?kind=(theme|plugin|strings)&url=https%3A%2F%2Fwaybetter\.fyi%2Fstore%2F/.test(tag)]).toEqual([page.path, true]); } } expect(buttons).toBeGreaterThan(catalog.items.length * 3 * CODES.length); }, SLOW); test('every page carries the words its scripts say, in its language', () => { const all = loadCatalogs(ROOT); for (const page of pages) { const words = JSON.parse(/<script type="application\/json" id="wb-i18n">([\s\S]*?)<\/script>/.exec(page.html)![1]!) as Record<string, string>; const own = all.get(page.lang)!.messages; expect([page.path, words['site.js.copied']]).toEqual([page.path, own['site.js.copied']!]); expect([page.path, Object.keys(words).every(key => key.startsWith('site.js.'))]).toEqual([page.path, true]); } }, SLOW); }); describe('helpers', () => { test('clip keeps whole sentences, else whole words', () => { expect(clip('One sentence. Two sentences here.', 20)).toBe('One sentence.'); expect(clip('Points t.co links in posts straight at the destination', 30)).toBe('Points t.co links in posts…'); expect(clip('short', 20)).toBe('short'); }, SLOW); test('the "Latest" shelf appears only when the dates tell something', () => { const items = catalog.items.slice(0, 5); expect(latestItems(items, () => '2026-09-29')).toEqual([]); expect(latestItems(items, () => undefined)).toEqual([]); const dates = ['2026-09-29', '2026-10-02', '2026-09-29', '2026-10-05', '2026-09-29']; expect(latestItems(items, item => dates[items.indexOf(item)], 2)).toEqual([items[3]!, items[1]!]); }, SLOW); });