2026-09-02 16:11:48 +01:00
|
|
|
import { metadata as homeMetadata } from '@/app/(frontend)/page';
|
|
|
|
|
import { metadata as rankingsMetadata } from '@/app/(frontend)/rankings/page';
|
|
|
|
|
import { metadata as admissionsMetadata } from '@/app/(frontend)/admissions/page';
|
|
|
|
|
import { generateMetadata as compareMetadata } from '@/app/(frontend)/compare/page';
|
2026-09-14 21:54:32 +01:00
|
|
|
import { metadata as rootMetadata } from '@/app/(frontend)/layout';
|
2026-08-20 22:10:50 +01:00
|
|
|
|
|
|
|
|
describe('canonical URLs', () => {
|
|
|
|
|
it('the homepage canonicalises to the bare root', () => {
|
|
|
|
|
// page.tsx reads eleven search params. Without this, every filter
|
|
|
|
|
// combination is a crawlable near-duplicate of the one page we want to
|
|
|
|
|
// rank for "compare schools".
|
|
|
|
|
expect(homeMetadata.alternates?.canonical)
|
|
|
|
|
.toBe('https://www.schoolcompare.co.uk/');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rankings canonicalises to the bare path', () => {
|
|
|
|
|
expect(rankingsMetadata.alternates?.canonical)
|
|
|
|
|
.toBe('https://www.schoolcompare.co.uk/rankings');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('admissions canonicalises to the bare path', () => {
|
|
|
|
|
expect(admissionsMetadata.alternates?.canonical)
|
|
|
|
|
.toBe('https://www.schoolcompare.co.uk/admissions');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-20 22:11:34 +01:00
|
|
|
|
|
|
|
|
describe('/compare indexability', () => {
|
|
|
|
|
it('the bare compare page is indexable and canonical to itself', async () => {
|
|
|
|
|
// This is the landing page for the "compare schools" head term.
|
|
|
|
|
const meta = await compareMetadata({ searchParams: Promise.resolve({}) });
|
|
|
|
|
expect(meta.alternates?.canonical)
|
|
|
|
|
.toBe('https://www.schoolcompare.co.uk/compare');
|
|
|
|
|
expect(meta.robots).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a comparison of specific schools is noindex, follow', async () => {
|
|
|
|
|
// ~317 million pairs before triples. Indexing the parameter space would
|
|
|
|
|
// swamp everything else in the corpus.
|
|
|
|
|
const meta = await compareMetadata({
|
|
|
|
|
searchParams: Promise.resolve({ urns: '100001,100002' }),
|
|
|
|
|
});
|
|
|
|
|
expect(meta.robots).toEqual({ index: false, follow: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a parameterised comparison still canonicalises to the bare path', async () => {
|
|
|
|
|
// follow:true plus a canonical means the outbound links to each school
|
|
|
|
|
// page still pass value even though this URL is not indexed.
|
|
|
|
|
const meta = await compareMetadata({
|
|
|
|
|
searchParams: Promise.resolve({ urns: '100001,100002' }),
|
|
|
|
|
});
|
|
|
|
|
expect(meta.alternates?.canonical)
|
|
|
|
|
.toBe('https://www.schoolcompare.co.uk/compare');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-21 00:21:08 +01:00
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* W8 — snippet copy for the C1 cluster.
|
|
|
|
|
*
|
|
|
|
|
* The baseline (GSC, 16 months to 2026-08-20) showed these pages ranking on
|
|
|
|
|
* page one and converting at a tenth of the normal rate: "compare school
|
|
|
|
|
* performance" at position 6.1 with 0.43% CTR, against 9.16% for the brand
|
|
|
|
|
* query from the same neighbourhood. The SERP is dominated by the DfE's own
|
|
|
|
|
* "Compare school performance" service, so the job of this copy is to say
|
|
|
|
|
* what that service does not offer, without losing intent match on the title.
|
|
|
|
|
*
|
|
|
|
|
* These tests guard the mechanics that make a snippet work — length, intent
|
|
|
|
|
* keyword, differentiator, no brand-first — not the exact wording, which
|
|
|
|
|
* should stay free to iterate.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// Google truncates titles near 60 characters and descriptions near 155.
|
|
|
|
|
const TITLE_MAX = 60;
|
|
|
|
|
const DESC_MIN = 110;
|
|
|
|
|
const DESC_MAX = 155;
|
|
|
|
|
|
|
|
|
|
type Meta = { title?: unknown; description?: unknown };
|
|
|
|
|
const titleOf = (m: Meta): string => {
|
|
|
|
|
const t = m.title as string | { absolute?: string } | undefined;
|
|
|
|
|
return typeof t === 'string' ? t : (t?.absolute ?? '');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe('C1 snippet copy', () => {
|
|
|
|
|
const pages: Array<[string, Meta, RegExp]> = [
|
|
|
|
|
['home', homeMetadata as Meta, /compare schools/i],
|
|
|
|
|
['rankings', rankingsMetadata as Meta, /league table/i],
|
|
|
|
|
['admissions', admissionsMetadata as Meta, /admission/i],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const [name, meta, intent] of pages) {
|
|
|
|
|
it(`${name}: title carries the search intent and fits the SERP`, () => {
|
|
|
|
|
const t = titleOf(meta);
|
|
|
|
|
expect(t).toMatch(intent);
|
|
|
|
|
expect(t.length).toBeLessThanOrEqual(TITLE_MAX);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it(`${name}: title does not open with the brand`, () => {
|
|
|
|
|
// The measured 0.43% CTR came from a brand-first title. The most
|
|
|
|
|
// valuable pixels go to the thing the searcher typed.
|
|
|
|
|
expect(titleOf(meta).toLowerCase().startsWith('schoolcompare')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it(`${name}: description is long enough to be worth reading, short enough to survive`, () => {
|
|
|
|
|
const d = meta.description as string;
|
|
|
|
|
expect(d.length).toBeGreaterThanOrEqual(DESC_MIN);
|
|
|
|
|
expect(d.length).toBeLessThanOrEqual(DESC_MAX);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('the homepage description names what gov.uk does not publish', () => {
|
|
|
|
|
// Admissions distance is the one fact the DfE service has no equivalent
|
|
|
|
|
// for. If it ever leaves this description, the snippet is competing with
|
|
|
|
|
// gov.uk on gov.uk's own ground.
|
|
|
|
|
expect(homeMetadata.description).toMatch(/close you had to live|distance/i);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('/compare targets the tool phrasing rather than repeating the homepage', () => {
|
|
|
|
|
// Two pages chasing one phrase is how a site competes with itself.
|
|
|
|
|
return compareMetadata({ searchParams: Promise.resolve({}) }).then((m) => {
|
|
|
|
|
expect(m.title).toMatch(/comparison tool/i);
|
|
|
|
|
expect(m.title).not.toBe(titleOf(homeMetadata as Meta));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('no C1 page claims a school count that will drift', () => {
|
|
|
|
|
// The corpus moves with every data refresh; this repo has already shipped
|
2026-09-30 11:56:54 +01:00
|
|
|
// one copy bug of that kind ("three schools" against a limit of five).
|
2026-08-21 00:21:08 +01:00
|
|
|
for (const [, meta] of pages) {
|
|
|
|
|
expect(meta.description as string).not.toMatch(/\b\d{2},\d{3}\b|\b\d{2},000\b/);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-09-14 21:54:32 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The share card must be declared, not inherited.
|
|
|
|
|
*
|
|
|
|
|
* `app/opengraph-image.tsx` is a metadata file convention, and it does attach
|
|
|
|
|
* to routes in the app root segment — `_not-found` gets an og:image from it.
|
|
|
|
|
* It does NOT attach to the site's pages, which live in the `(frontend)`
|
|
|
|
|
* route group whose own layout is a root layout. Staging served og:title,
|
|
|
|
|
* og:description, og:url, og:site_name and og:type and no og:image at all,
|
|
|
|
|
* so every link pasted into a chat rendered bare.
|
|
|
|
|
*
|
|
|
|
|
* The file stays at the app root, because /robots.txt and /icon.png depend on
|
|
|
|
|
* it being there. The site's root layout points at the route it generates.
|
|
|
|
|
*/
|
|
|
|
|
describe('the share card', () => {
|
|
|
|
|
it('declares an opengraph image on the site root layout', () => {
|
|
|
|
|
// No og:image means every link pasted into a chat renders bare.
|
|
|
|
|
const images = rootMetadata.openGraph?.images;
|
|
|
|
|
expect(images).toBeTruthy();
|
|
|
|
|
expect(JSON.stringify(images)).toContain('/opengraph-image');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('declares a twitter image too', () => {
|
|
|
|
|
// twitter.card is summary_large_image. Claiming a large-image card and
|
|
|
|
|
// supplying no image is worse than claiming a summary card.
|
|
|
|
|
// Metadata['twitter'] is a union and `card` is not on every member, so
|
|
|
|
|
// this reads the serialised shape rather than narrowing the type.
|
|
|
|
|
const twitter = JSON.stringify(rootMetadata.twitter);
|
|
|
|
|
expect(twitter).toContain('summary_large_image');
|
|
|
|
|
expect(twitter).toContain('/opengraph-image');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('resolves the card to an absolute url via metadataBase', () => {
|
|
|
|
|
// The e2e journey does `new URL(ogUrl)`, which throws on a relative path.
|
|
|
|
|
expect(rootMetadata.metadataBase?.toString())
|
|
|
|
|
.toBe('https://www.schoolcompare.co.uk/');
|
|
|
|
|
});
|
|
|
|
|
});
|