test: break down metadata test suite into smaller ones (#67018)

### What

app-dir `metadata.test.ts` is pretty big and includes a lot of erroring
tests and navigation tests. Breaking them into smaller suites to avoid
the erroring on effect on others.

- metadata
- metadata-navigation
- metadata-thrown

Moved the metadata testing utils into `next-test-utils` for sharing
purpose.
Moved the hmr test to the bottom to avoid flakyness.
This commit is contained in:
Jiachi Liu 2024-06-19 14:46:21 +02:00 committed by GitHub
parent 9ff5c44989
commit 54f54423d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 317 additions and 246 deletions

View file

@ -0,0 +1,14 @@
export default function Layout({ children }) {
return (
<html>
<head></head>
<body>{children}</body>
</html>
)
}
export const metadata = {
title: 'this is the layout title',
description: 'this is the layout description',
keywords: ['nextjs', 'react'],
}

View file

@ -0,0 +1,76 @@
import { nextTestSetup } from 'e2e-utils'
import {
createMultiDomMatcher,
createMultiHtmlMatcher,
getTitle,
} from 'next-test-utils'
describe('app dir - metadata navigation', () => {
const { next } = nextTestSetup({
files: __dirname,
})
describe('navigation', () => {
it('should render root not-found with default metadata', async () => {
const $ = await next.render$('/does-not-exist')
// Should contain default metadata and noindex tag
const matchHtml = createMultiHtmlMatcher($)
expect($('meta[charset="utf-8"]').length).toBe(1)
matchHtml('meta', 'name', 'content', {
viewport: 'width=device-width, initial-scale=1',
robots: 'noindex',
// not found metadata
description: 'Root not found description',
})
expect(await $('title').text()).toBe('Root not found')
})
it('should support notFound in generateMetadata', async () => {
const res = await next.fetch('/async/not-found')
expect(res.status).toBe(404)
const $ = await next.render$('/async/not-found')
// TODO-APP: support render custom not-found in SSR for generateMetadata.
// Check contains root not-found payload in flight response for now.
let hasRootNotFoundFlight = false
for (const el of $('script').toArray()) {
const text = $(el).text()
if (text.includes('Local found boundary')) {
hasRootNotFoundFlight = true
}
}
expect(hasRootNotFoundFlight).toBe(true)
// Should contain default metadata and noindex tag
const matchHtml = createMultiHtmlMatcher($)
expect($('meta[charset="utf-8"]').length).toBe(1)
matchHtml('meta', 'name', 'content', {
viewport: 'width=device-width, initial-scale=1',
robots: 'noindex',
})
const browser = await next.browser('/async/not-found')
expect(await browser.elementByCss('h2').text()).toBe(
'Local found boundary'
)
const matchMultiDom = createMultiDomMatcher(browser)
await matchMultiDom('meta', 'name', 'content', {
viewport: 'width=device-width, initial-scale=1',
keywords: 'parent',
robots: 'noindex',
// not found metadata
description: 'Local not found description',
})
expect(await getTitle(browser)).toBe('Local not found')
})
it('should support redirect in generateMetadata', async () => {
const res = await next.fetch('/async/redirect', {
redirect: 'manual',
})
expect(res.status).toBe(307)
})
})
})

View file

@ -0,0 +1,12 @@
import { nextTestSetup } from 'e2e-utils'
describe('app dir - metadata thrown', () => {
const { next } = nextTestSetup({
files: __dirname,
})
it('should not crash from error thrown during preloading nested generateMetadata', async () => {
const res = await next.fetch('/dynamic-meta')
expect(res.status).toBe(404)
})
})

View file

@ -0,0 +1,19 @@
function format({ params, searchParams }) {
const { slug } = params
const { q } = searchParams
return `params - ${slug}${q ? ` query - ${q}` : ''}`
}
export default function page(props) {
return <p>{format(props)}</p>
}
export async function generateMetadata(props, parent) {
const parentMetadata = await parent
/* mutating */
return {
...parentMetadata,
title: format(props),
keywords: parentMetadata.keywords.concat(['child']),
}
}

View file

@ -0,0 +1,11 @@
export default function layout({ children }) {
return children
}
export async function generateMetadata() {
return {
keywords: 'parent',
}
}
export const revalidate = 0

View file

@ -1,162 +1,21 @@
import type { BrowserInterface } from 'next-webdriver'
import { nextTestSetup } from 'e2e-utils'
import { check } from 'next-test-utils'
import {
check,
getTitle,
createDomMatcher,
createMultiHtmlMatcher,
createMultiDomMatcher,
checkMetaNameContentPair,
checkLink,
} from 'next-test-utils'
import fs from 'fs/promises'
import path from 'path'
import cheerio from 'cheerio'
describe('app dir - metadata', () => {
const { next, isNextDev, isNextStart, isNextDeploy } = nextTestSetup({
files: __dirname,
})
const getTitle = (browser: BrowserInterface) =>
browser.elementByCss('title').text()
async function checkMeta(
browser: BrowserInterface,
queryValue: string,
expected: RegExp | string | string[] | undefined | null,
queryKey: string = 'property',
tag: string = 'meta',
domAttributeField: string = 'content'
) {
const values = await browser.eval(
`[...document.querySelectorAll('${tag}[${queryKey}="${queryValue}"]')].map((el) => el.getAttribute("${domAttributeField}"))`
)
if (expected instanceof RegExp) {
expect(values[0]).toMatch(expected)
} else {
if (Array.isArray(expected)) {
expect(values).toEqual(expected)
} else {
// If expected is undefined, then it should not exist.
// Otherwise, it should exist in the matched values.
if (expected === undefined) {
expect(values).not.toContain(undefined)
} else {
expect(values).toContain(expected)
}
}
}
}
function createDomMatcher(browser: BrowserInterface) {
/**
* @param tag - tag name, e.g. 'meta'
* @param query - query string, e.g. 'name="description"'
* @param expectedObject - expected object, e.g. { content: 'my description' }
* @returns {Promise<void>} - promise that resolves when the check is done
*
* @example
* const matchDom = createDomMatcher(browser)
* await matchDom('meta', 'name="description"', { content: 'description' })
*/
return async (
tag: string,
query: string,
expectedObject: Record<string, string | null | undefined>
) => {
const props = await browser.eval(`
const el = document.querySelector('${tag}[${query}]');
const res = {}
const keys = ${JSON.stringify(Object.keys(expectedObject))}
for (const k of keys) {
res[k] = el?.getAttribute(k)
}
res
`)
expect(props).toEqual(expectedObject)
}
}
function createMultiHtmlMatcher($: ReturnType<typeof cheerio.load>) {
/**
* @param tag - tag name, e.g. 'meta'
* @param queryKey - query key, e.g. 'property'
* @param domAttributeField - dom attribute field, e.g. 'content'
* @param expected - expected object, e.g. { description: 'my description' }
* @returns {void} - void when the check is done
*
* @example
*
* const $ = await next.render$('html')
* const matchHtml = createMultiHtmlMatcher($)
* matchHtml('meta', 'name', 'property', {
* description: 'description',
* og: 'og:description'
* })
*
*/
return (
tag: string,
queryKey: string,
domAttributeField: string,
expected: Record<string, string | string[] | undefined>
) => {
const res = {}
for (const key of Object.keys(expected)) {
const el = $(`${tag}[${queryKey}="${key}"]`)
if (el.length > 1) {
res[key] = el.toArray().map((el) => el.attribs[domAttributeField])
} else {
res[key] = el.attr(domAttributeField)
}
}
expect(res).toEqual(expected)
}
}
function createMultiDomMatcher(browser: BrowserInterface) {
/**
* @param tag - tag name, e.g. 'meta'
* @param queryKey - query key, e.g. 'property'
* @param domAttributeField - dom attribute field, e.g. 'content'
* @param expected - expected object, e.g. { description: 'my description' }
* @returns {Promise<void>} - promise that resolves when the check is done
*
* @example
* const matchMultiDom = createMultiDomMatcher(browser)
* await matchMultiDom('meta', 'property', 'content', {
* description: 'description',
* 'og:title': 'title',
* 'twitter:title': 'title'
* })
*
*/
return async (
tag: string,
queryKey: string,
domAttributeField: string,
expected: Record<string, string | string[] | undefined | null>
) => {
await Promise.all(
Object.keys(expected).map(async (key) => {
return checkMeta(
browser,
key,
expected[key],
queryKey,
tag,
domAttributeField
)
})
)
}
}
const checkMetaNameContentPair = (
browser: BrowserInterface,
name: string,
content: string | string[]
) => checkMeta(browser, name, content, 'name')
const checkLink = (
browser: BrowserInterface,
rel: string,
content: string | string[]
) => checkMeta(browser, rel, content, 'rel', 'link', 'href')
describe('basic', () => {
it('should support title and description', async () => {
const browser = await next.browser('/title')
@ -423,13 +282,13 @@ describe('app dir - metadata', () => {
expect(await getTitle(browser)).toBe('this is the page title')
})
it('should support generateMetadata export', async () => {
const browser = await next.browser('/async/slug')
it('should support generateMetadata dynamic props', async () => {
const browser = await next.browser('/dynamic/slug')
expect(await getTitle(browser)).toBe('params - slug')
await checkMetaNameContentPair(browser, 'keywords', 'parent,child')
await browser.loadPage(next.url + '/async/blog?q=xxx')
await browser.loadPage(next.url + '/dynamic/blog?q=xxx')
await check(
() => browser.elementByCss('p').text(),
/params - blog query - xxx/
@ -566,71 +425,6 @@ describe('app dir - metadata', () => {
})
})
describe('navigation', () => {
it('should render root not-found with default metadata', async () => {
const $ = await next.render$('/does-not-exist')
// Should contain default metadata and noindex tag
const matchHtml = createMultiHtmlMatcher($)
expect($('meta[charset="utf-8"]').length).toBe(1)
matchHtml('meta', 'name', 'content', {
viewport: 'width=device-width, initial-scale=1',
robots: 'noindex',
// not found metadata
description: 'Root not found description',
})
expect(await $('title').text()).toBe('Root not found')
})
it('should support notFound in generateMetadata', async () => {
const res = await next.fetch('/async/not-found')
expect(res.status).toBe(404)
const html = await res.text()
const $ = cheerio.load(html)
// TODO-APP: support render custom not-found in SSR for generateMetadata.
// Check contains root not-found payload in flight response for now.
let hasRootNotFoundFlight = false
for (const el of $('script').toArray()) {
const text = $(el).text()
if (text.includes('Local found boundary')) {
hasRootNotFoundFlight = true
}
}
expect(hasRootNotFoundFlight).toBe(true)
// Should contain default metadata and noindex tag
const matchHtml = createMultiHtmlMatcher($)
expect($('meta[charset="utf-8"]').length).toBe(1)
matchHtml('meta', 'name', 'content', {
viewport: 'width=device-width, initial-scale=1',
robots: 'noindex',
})
const browser = await next.browser('/async/not-found')
expect(await browser.elementByCss('h2').text()).toBe(
'Local found boundary'
)
const matchMultiDom = createMultiDomMatcher(browser)
await matchMultiDom('meta', 'name', 'content', {
viewport: 'width=device-width, initial-scale=1',
keywords: 'parent',
robots: 'noindex',
// not found metadata
description: 'Local not found description',
})
expect(await getTitle(browser)).toBe('Local not found')
})
it('should support redirect in generateMetadata', async () => {
const res = await next.fetch('/async/redirect', {
redirect: 'manual',
})
expect(res.status).toBe(307)
})
})
describe('icons', () => {
it('should support basic object icons field', async () => {
const browser = await next.browser('/icons')
@ -757,31 +551,6 @@ describe('app dir - metadata', () => {
const dynamicIconRes = await next.fetch(dynamicIconHref)
expect(dynamicIconRes.status).toBe(200)
})
if (isNextDev) {
// This test frequently causes a compilation error when run in Turbopack
// which also causes all subsequent tests to fail. Disabled while we investigate to reduce flakes.
;(process.env.TURBOPACK ? it.skip : it)(
'should handle updates to the file icon name and order',
async () => {
await next.renameFile(
'app/icons/static/icon.png',
'app/icons/static/icon2.png'
)
await check(async () => {
const $ = await next.render$('/icons/static')
const $icon = $('head > link[rel="icon"][type!="image/x-icon"]')
return $icon.attr('href')
}, /\/icons\/static\/icon2/)
await next.renameFile(
'app/icons/static/icon2.png',
'app/icons/static/icon.png'
)
}
)
}
})
describe('twitter', () => {
@ -1020,8 +789,30 @@ describe('app dir - metadata', () => {
expect(ogHtml).toContain('pages-opengraph-image-page')
})
it('should not crash from error thrown during preloading nested generateMetadata', async () => {
const res = await next.fetch('/dynamic-meta')
expect(res.status).toBe(404)
describe('hmr', () => {
if (isNextDev) {
// This test frequently causes a compilation error when run in Turbopack
// which also causes all subsequent tests to fail. Disabled while we investigate to reduce flakes.
;(process.env.TURBOPACK ? it.skip : it)(
'should handle updates to the file icon name and order',
async () => {
await next.renameFile(
'app/icons/static/icon.png',
'app/icons/static/icon2.png'
)
await check(async () => {
const $ = await next.render$('/icons/static')
const $icon = $('head > link[rel="icon"][type!="image/x-icon"]')
return $icon.attr('href')
}, /\/icons\/static\/icon2/)
await next.renameFile(
'app/icons/static/icon2.png',
'app/icons/static/icon.png'
)
}
)
}
})
})

View file

@ -10,6 +10,7 @@ import { promisify } from 'util'
import http from 'http'
import path from 'path'
import cheerio from 'cheerio'
import spawn from 'cross-spawn'
import { writeFile } from 'fs-extra'
import getPort from 'get-port'
@ -1232,3 +1233,150 @@ export const describeVariants = {
}
},
}
export const getTitle = (browser: BrowserInterface) =>
browser.elementByCss('title').text()
async function checkMeta(
browser: BrowserInterface,
queryValue: string,
expected: RegExp | string | string[] | undefined | null,
queryKey: string = 'property',
tag: string = 'meta',
domAttributeField: string = 'content'
) {
const values = await browser.eval(
`[...document.querySelectorAll('${tag}[${queryKey}="${queryValue}"]')].map((el) => el.getAttribute("${domAttributeField}"))`
)
if (expected instanceof RegExp) {
expect(values[0]).toMatch(expected)
} else {
if (Array.isArray(expected)) {
expect(values).toEqual(expected)
} else {
// If expected is undefined, then it should not exist.
// Otherwise, it should exist in the matched values.
if (expected === undefined) {
expect(values).not.toContain(undefined)
} else {
expect(values).toContain(expected)
}
}
}
}
export function createDomMatcher(browser: BrowserInterface) {
/**
* @param tag - tag name, e.g. 'meta'
* @param query - query string, e.g. 'name="description"'
* @param expectedObject - expected object, e.g. { content: 'my description' }
* @returns {Promise<void>} - promise that resolves when the check is done
*
* @example
* const matchDom = createDomMatcher(browser)
* await matchDom('meta', 'name="description"', { content: 'description' })
*/
return async (
tag: string,
query: string,
expectedObject: Record<string, string | null | undefined>
) => {
const props = await browser.eval(`
const el = document.querySelector('${tag}[${query}]');
const res = {}
const keys = ${JSON.stringify(Object.keys(expectedObject))}
for (const k of keys) {
res[k] = el?.getAttribute(k)
}
res
`)
expect(props).toEqual(expectedObject)
}
}
export function createMultiHtmlMatcher($: ReturnType<typeof cheerio.load>) {
/**
* @param tag - tag name, e.g. 'meta'
* @param queryKey - query key, e.g. 'property'
* @param domAttributeField - dom attribute field, e.g. 'content'
* @param expected - expected object, e.g. { description: 'my description' }
* @returns {void} - void when the check is done
*
* @example
*
* const $ = await next.render$('html')
* const matchHtml = createMultiHtmlMatcher($)
* matchHtml('meta', 'name', 'property', {
* description: 'description',
* og: 'og:description'
* })
*
*/
return (
tag: string,
queryKey: string,
domAttributeField: string,
expected: Record<string, string | string[] | undefined>
) => {
const res = {}
for (const key of Object.keys(expected)) {
const el = $(`${tag}[${queryKey}="${key}"]`)
if (el.length > 1) {
res[key] = el.toArray().map((el) => el.attribs[domAttributeField])
} else {
res[key] = el.attr(domAttributeField)
}
}
expect(res).toEqual(expected)
}
}
export function createMultiDomMatcher(browser: BrowserInterface) {
/**
* @param tag - tag name, e.g. 'meta'
* @param queryKey - query key, e.g. 'property'
* @param domAttributeField - dom attribute field, e.g. 'content'
* @param expected - expected object, e.g. { description: 'my description' }
* @returns {Promise<void>} - promise that resolves when the check is done
*
* @example
* const matchMultiDom = createMultiDomMatcher(browser)
* await matchMultiDom('meta', 'property', 'content', {
* description: 'description',
* 'og:title': 'title',
* 'twitter:title': 'title'
* })
*
*/
return async (
tag: string,
queryKey: string,
domAttributeField: string,
expected: Record<string, string | string[] | undefined | null>
) => {
await Promise.all(
Object.keys(expected).map(async (key) => {
return checkMeta(
browser,
key,
expected[key],
queryKey,
tag,
domAttributeField
)
})
)
}
}
export const checkMetaNameContentPair = (
browser: BrowserInterface,
name: string,
content: string | string[]
) => checkMeta(browser, name, content, 'name')
export const checkLink = (
browser: BrowserInterface,
rel: string,
content: string | string[]
) => checkMeta(browser, rel, content, 'rel', 'link', 'href')