diff --git a/website/test/ssr/seo-infra.test.ts b/website/test/ssr/seo-infra.test.ts index 300af2ebe..e191e4c19 100644 --- a/website/test/ssr/seo-infra.test.ts +++ b/website/test/ssr/seo-infra.test.ts @@ -23,11 +23,9 @@ import assert from 'node:assert/strict'; import { readFileSync, statSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { html } from '@webjsdev/core'; -import { renderToString } from '@webjsdev/core/server'; -import RootLayout, { generateMetadata } from '#app/layout.ts'; +import { createRequestHandler } from '@webjsdev/server'; +import { generateMetadata } from '#app/layout.ts'; import Robots from '#app/robots.ts'; -import { layoutProps } from '#test/helpers/layout-props.ts'; const WEBSITE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); @@ -37,13 +35,39 @@ function pngSize(path: string): { width: number; height: number } { return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; } +/** + * The icon `` tags the site actually serves, in head order. + * + * These read the SERVED page rather than `renderToString(RootLayout(...))`. + * The layout used to hand-write its icon tags into its own shell, so rendering + * the layout alone saw them; they are declared through `metadata.icons` now and + * the framework splices them into , which a bare layout render cannot + * show. Rendering the app is also the more honest assertion: the tags could + * come from markup, from `metadata.icons`, or from an auto-linked `app/icon.*` + * route, and a browser cannot tell the difference, so neither should this. + */ +async function servedIconLinks(): Promise { + const app = await createRequestHandler({ appDir: WEBSITE_ROOT, dev: true }); + const res = await app.handle(new Request('http://localhost/')); + assert.equal(res.status, 200, 'the home page renders'); + // Browsers ignore a favicon in , so only the head counts. + const head = (await res.text()).split('')[0]; + return [...head.matchAll(/]*>/g)].map((m) => m[0]); +} + +/** Pull an attribute off one rendered tag, order-independently. */ +const attr = (tag: string, name: string): string | undefined => + (tag.match(new RegExp(`${name}="([^"]*)"`)) || [])[1]; + test('the declared favicon size matches the real asset and clears Google 48px floor', async () => { - const out = await renderToString(RootLayout(layoutProps(html`
x
`))); - const match = out.match(/]*sizes="(\d+)x(\d+)"/); - assert.ok(match, 'declares a PNG icon with an explicit sizes attribute'); + const png = (await servedIconLinks()).find((l) => attr(l, 'type') === 'image/png'); + assert.ok(png, 'serves a PNG icon with an explicit type'); - const [, file, declaredW, declaredH] = match!; + const sizes = attr(png!, 'sizes'); + assert.ok(sizes, 'the PNG icon declares a size'); + const [declaredW, declaredH] = sizes!.split('x'); const declared = Number(declaredW); + const file = attr(png!, 'href')!.replace(/^\/public\//, ''); const { width, height } = pngSize(resolve(WEBSITE_ROOT, 'public', file)); assert.equal(declaredW, declaredH, 'declared as square'); @@ -57,21 +81,34 @@ test('the declared favicon size matches the real asset and clears Google 48px fl }); test('the apple-touch icon points at a correctly sized asset', async () => { - const out = await renderToString(RootLayout(layoutProps(html`
x
`))); - const match = out.match(/ attr(l, 'rel') === 'apple-touch-icon'); + assert.ok(apple, 'serves an apple-touch-icon'); + const file = attr(apple!, 'href')!.replace(/^\/public\//, ''); + const { width } = pngSize(resolve(WEBSITE_ROOT, 'public', file)); + assert.equal(Number(attr(apple!, 'sizes')!.split('x')[0]), width, + 'the declared apple-touch size matches the real asset'); }); -test('the raster icon is declared before the SVG', async () => { +test('the raster icon is served before the SVG', async () => { // Google's favicon crawler takes the first usable icon. The SVG led before, // and raster is the format search results reliably render. - const out = await renderToString(RootLayout(layoutProps(html`
x
`))); - const png = out.indexOf('type="image/png"'); - const svg = out.indexOf('href="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/public/favicon.svg"'); - assert.ok(png > -1 && svg > -1, 'both icons are declared'); - assert.ok(png < svg, 'the PNG is declared ahead of the SVG'); + const links = await servedIconLinks(); + const png = links.findIndex((l) => attr(l, 'type') === 'image/png'); + const svg = links.findIndex((l) => attr(l, 'type') === 'image/svg+xml'); + assert.ok(png > -1 && svg > -1, 'serves both a PNG and an SVG icon'); + assert.ok(png < svg, 'the PNG comes first in the head'); +}); + +test('every icon the site links is actually served', async () => { + // A head naming a URL nothing answers is invisible from the app and only + // shows as a missing tab mark, which is exactly how this class of defect + // reached production on gallery.webjs.dev. + const app = await createRequestHandler({ appDir: WEBSITE_ROOT, dev: true }); + for (const link of await servedIconLinks()) { + const href = attr(link, 'href')!; + const res = await app.handle(new Request(`http://localhost${href}`)); + assert.equal(res.status, 200, `${href} is served, not a 404`); + } }); test('favicon.ico exists so the origin-root fallback resolves', () => {