A page title is a contract with a search result, and the contract is about length. Over roughly 60 characters it gets truncated; a description over about 160 goes the same way. TypeScript will happily let you write a 200 character description, your build will pass, your page will render, and you will find out from a screenshot of a search result six weeks later.
So we test it. The interesting part is not the assertion, it is that the test never imports a single page.
That walk finds 100 files in our app directory. The reason the test reads them with readFileSync instead of importing them is in the comment at the top of the file:
Which is the whole argument. import('./app/pricing/page') in a Next.js app is not a cheap operation. It evaluates the module, which evaluates its imports, which means a database client, a Stripe client, environment variable access and whatever else the page happens to touch. To measure a string. In a suite that is supposed to run in under a second.
Reading the file as text has a cost, which is that you are now parsing TypeScript with regular expressions, and we will get to what that cost actually is further down. It is smaller than people assume, because the thing being extracted is a string literal in a known position in a file written by the same team, not arbitrary source.
Two functions. The first slices out the metadata export, the second pulls a field out of it:
The "closes at column zero" trick is what makes this work without a parser. A nested object inside the metadata declaration is indented; the line that ends the declaration is not. It handles both spellings we use, the plain object literal ending in } and the pageMetadata({ ... }) call ending in }), and it will fail loudly rather than quietly if somebody invents a third.
stringField is the fiddly one. The capture group matches the opening quote, then anything that is not that same quote unless escaped, then the same quote again. That backreference is the only reason title: "Don't panic" and title: 'Say "hello"' both come out right.
Most of our public pages do not write their metadata by hand. They get it from a content registry, a typed module holding one node per page with its title, heading, description, date and sideways links, and the page calls contentMetadata('/its/path'). So the test skips them:
The arithmetic across our 100 route files comes out as: 69 call contentMetadata(), and are covered by a different suite of 21 tests that checks the same length budget plus the whole internal link graph 13 write their own metadata, which is this test's job 18 export none at all
That last group is not an oversight either. Fourteen of the eighteen are pages nobody is allowed to index: the eleven dashboard pages, the player page, the post-checkout page and one error page in the invite flow, each sitting under a segment whose layout already sets robots: { index: false }. A page that will never appear in a search result does not need a title tuned for one. The remaining four are structural layouts: one wraps the legal pages in a light-theme shell with their own header, and one of the others is literally return <>{children}. The page inside each of them is the thing with the title.
Every test that discovers its own inputs needs one of these. The failure mode of a source-reading test is not a false negative on one page, it is the day somebody changes export const metadata to export const metadata: Metadata = buildMeta(...), the regex matches nothing, PAGES becomes empty, and both length tests pass by iterating over zero items. Green suite, zero coverage, no signal. The loop-based assertions cannot tell you that, so a separate assertion counts the inputs.
The titles in our page files are short, because the root layout appends the site name:
Which means the number in the file is not the number a crawler sees, and a test asserting on the raw string is testing the wrong string:
| PubTrivia is 12 characters, so the template spends a fifth of the budget before the page has said anything. SITE_NAME is read from the shared publisher module rather than typed in, so renaming the product does not leave a test asserting against the old name.
The custom message in the second argument is there because of what the failure looks like without it: expected 183 to be less than or equal to 160, with no indication of which of thirteen files is at fault. With it, the failure names the file, the count and the string.
You can check the rendered numbers against the live site in one line, and they match the source exactly:
| Page | Title | Description | |---|---|---| | /login | 19 | 99 | | /legal/privacy | 26 | 95 | | /too-many-requests | 29 | 72 | | /forgot-password | 31 | 69 | | /signup | 36 | 129 | | /faq | 38 | 150 |
