import { nextTestSetup } from 'e2e-utils' import { check, getTitle, createDomMatcher, createMultiHtmlMatcher, createMultiDomMatcher, checkMetaNameContentPair, checkLink, retry, } from 'next-test-utils' import fs from 'fs/promises' import path from 'path' // Webpack: /favicon.ico? // Turbopack: /favicon.ico?favicon..ico const FAVICON_REGEX = /\/favicon.ico\?\w+/ describe('app dir - metadata', () => { const { next, isNextDev, isNextStart, isNextDeploy } = nextTestSetup({ files: __dirname, }) describe('basic', () => { it('should support title and description', async () => { const browser = await next.browser('/title') expect(await browser.eval(`document.title`)).toBe( 'this is the page title' ) await checkMetaNameContentPair( browser, 'description', 'this is the layout description' ) }) it('should support title template', async () => { const browser = await next.browser('/title-template') // Use the parent layout (root layout) instead of app/title-template/layout.tsx expect(await browser.eval(`document.title`)).toBe('Page') }) it('should support stashed title in one layer of page and layout', async () => { const browser = await next.browser('/title-template/extra') // Use the parent layout (app/title-template/layout.tsx) instead of app/title-template/extra/layout.tsx expect(await browser.eval(`document.title`)).toBe('Extra Page | Layout') }) it('should use parent layout title when no title is defined in page', async () => { const browser = await next.browser('/title-template/use-layout-title') expect(await browser.eval(`document.title`)).toBe( 'title template layout default' ) }) it('should support stashed title in two layers of page and layout', async () => { const $inner = await next.render$('/title-template/extra/inner') expect(await $inner('title').text()).toBe('Inner Page | Extra Layout') const $deep = await next.render$('/title-template/extra/inner/deep') expect(await $deep('title').text()).toBe('extra layout default | Layout') }) it('should support other basic tags', async () => { const browser = await next.browser('/basic') const matchDom = createDomMatcher(browser) const matchMultiDom = createMultiDomMatcher(browser) await matchMultiDom('meta', 'name', 'content', { generator: 'next.js', 'application-name': 'test', referrer: 'origin-when-cross-origin', keywords: 'next.js,react,javascript', author: ['huozhi', 'tree'], 'color-scheme': 'dark', viewport: 'width=device-width, initial-scale=1, maximum-scale=1, interactive-widget=resizes-visual', creator: 'shu', publisher: 'vercel', robots: 'index, follow', 'format-detection': 'telephone=no, address=no, email=no', }) await matchMultiDom('link', 'rel', 'href', { manifest: '/api/manifest', author: 'https://tree.com', preconnect: '/preconnect-url', preload: '/api/preload', 'dns-prefetch': '/dns-prefetch-url', prev: '/basic?page=1', next: '/basic?page=3', }) // Manifest link should have crossOrigin attribute await matchDom('link', 'rel="manifest"', { href: '/api/manifest', crossOrigin: isNextDeploy ? 'use-credentials' : null, }) await matchDom('meta', 'name="theme-color"', { media: '(prefers-color-scheme: dark)', content: 'cyan', }) }) it('should support other basic tags (edge)', async () => { const browser = await next.browser('/basic-edge') const matchMultiDom = createMultiDomMatcher(browser) const matchDom = createDomMatcher(browser) await matchMultiDom('meta', 'name', 'content', { generator: 'next.js', 'application-name': 'test', referrer: 'origin-when-cross-origin', keywords: 'next.js,react,javascript', author: ['huozhi', 'tree'], robots: 'index, follow', 'format-detection': 'telephone=no, address=no, email=no', }) await matchMultiDom('link', 'rel', 'href', { manifest: '/api/manifest', author: 'https://tree.com', preconnect: '/preconnect-url', preload: '/api/preload', 'dns-prefetch': '/dns-prefetch-url', prev: '/basic?page=1', next: '/basic?page=3', }) // Manifest link should have crossOrigin attribute await matchDom('link', 'rel="manifest"', { href: '/api/manifest', crossOrigin: isNextDeploy ? 'use-credentials' : null, }) }) it('should support apple related tags `itunes` and `appWebApp`', async () => { const browser = await next.browser('/apple') const matchMultiDom = createMultiDomMatcher(browser) await matchMultiDom('meta', 'name', 'content', { 'apple-itunes-app': 'app-id=myAppStoreID, app-argument=myAppArgument', 'mobile-web-app-capable': 'yes', 'apple-mobile-web-app-title': 'Apple Web App', 'apple-mobile-web-app-status-bar-style': 'black-translucent', }) const matchDom = createDomMatcher(browser) await matchDom( 'link', 'href="/assets/startup/apple-touch-startup-image-768x1004.png"', { rel: 'apple-touch-startup-image', media: null, } ) await matchDom( 'link', 'href="/assets/startup/apple-touch-startup-image-1536x2008.png"', { rel: 'apple-touch-startup-image', media: '(device-width: 768px) and (device-height: 1024px)', } ) }) it('should support socials related tags like facebook and pinterest', async () => { const browser = await next.browser('/socials') const matchMultiDom = createMultiDomMatcher(browser) await matchMultiDom('meta', 'property', 'content', { 'fb:app_id': '12345678', 'fb:admins': ['120', '122', '124'], 'pinterest-rich-pin': 'true', }) }) it('should support alternate tags', async () => { const browser = await next.browser('/alternates') const matchDom = createDomMatcher(browser) await matchDom('link', 'rel="canonical"', { href: 'https://example.com/alternates', }) await matchDom('link', 'title="js title"', { type: 'application/rss+xml', href: 'https://example.com/blog/js.rss', }) await matchDom('link', 'title="rss"', { type: 'application/rss+xml', href: 'https://example.com/blog.rss', }) await matchDom('link', 'hreflang="en-US"', { rel: 'alternate', href: 'https://example.com/alternates/en-US', }) await matchDom('link', 'hreflang="de-DE"', { rel: 'alternate', href: 'https://example.com/alternates/de-DE', }) await matchDom('link', 'media="only screen and (max-width: 600px)"', { rel: 'alternate', href: 'https://example.com/mobile', }) }) it('should relative canonical url', async () => { const browser = await next.browser('/alternates/child') const matchDom = createDomMatcher(browser) await matchDom('link', 'rel="canonical"', { href: 'https://example.com/alternates/child', }) await matchDom('link', 'hreflang="en-US"', { rel: 'alternate', href: 'https://example.com/alternates/child/en-US', }) await matchDom('link', 'hreflang="de-DE"', { rel: 'alternate', href: 'https://example.com/alternates/child/de-DE', }) await browser.loadPage(next.url + '/alternates/child/123') await matchDom('link', 'rel="canonical"', { href: 'https://example.com/alternates/child/123', }) }) it('should not contain query in canonical url after client navigation', async () => { const browser = await next.browser('/') await browser.waitForElementByCss('p#index') await browser.eval(`next.router.push('/alternates')`) const matchDom = createDomMatcher(browser) // Dynamic metadata streams in async await retry(async () => { await matchDom('link', 'rel="canonical"', { href: 'https://example.com/alternates', }) await matchDom('link', 'title="js title"', { type: 'application/rss+xml', href: 'https://example.com/blog/js.rss', }) }) }) it('should support robots tags', async () => { const $ = await next.render$('/robots') const matchMultiDom = createMultiHtmlMatcher($) matchMultiDom('meta', 'name', 'content', { robots: 'noindex, follow, nocache', googlebot: 'index, nofollow, noimageindex, max-video-preview:standard, max-image-preview:-1, max-snippet:-1', }) }) it('should support verification tags', async () => { const $ = await next.render$('/verification') const matchMultiDom = createMultiHtmlMatcher($) matchMultiDom('meta', 'name', 'content', { 'google-site-verification': 'google', y_key: 'yahoo', 'yandex-verification': 'yandex', me: ['my-email', 'my-link'], }) expect($('meta[name="me"]').length).toBe(2) }) it('should support appLinks tags', async () => { const browser = await next.browser('/app-links') const matchMultiDom = createMultiDomMatcher(browser) await matchMultiDom('meta', 'property', 'content', { 'al:ios:url': 'https://example.com/ios', 'al:ios:app_store_id': 'app_store_id', 'al:android:package': 'com.example.android/package', 'al:android:app_name': 'app_name_android', 'al:web:should_fallback': 'true', }) }) it('should apply metadata when navigating client-side', async () => { const browser = await next.browser('/') expect(await getTitle(browser)).toBe('index page') await browser .elementByCss('#to-basic') .click() .waitForElementByCss('#basic') await retry(async () => { await checkMetaNameContentPair( browser, 'referrer', 'origin-when-cross-origin' ) }) await browser.back().waitForElementByCss('#index') expect(await getTitle(browser)).toBe('index page') await browser .elementByCss('#to-title') .click() .waitForElementByCss('#title') await retry(async () => { expect(await getTitle(browser)).toBe('this is the page title') }) }) 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 + '/dynamic/blog?q=xxx') await check( () => browser.elementByCss('p').text(), /params - blog query - xxx/ ) }) it('should handle metadataBase for urls resolved as only URL type', async () => { // including few urls in opengraph and alternates const url$ = await next.render$('/metadata-base/url') // compose with metadataBase expect(url$('link[rel="canonical"]').attr('href')).toBe( 'https://bar.example/url/subpath' ) // override metadataBase const urlInstance$ = await next.render$('/metadata-base/url-instance') expect(urlInstance$('meta[property="og:url"]').attr('content')).toBe( 'https://outerspace.com/huozhi.png' ) }) it('should handle metadataBase as url string', async () => { const url$ = await next.render$('/metadata-base/url-string') expect(url$('link[rel="canonical"]').attr('href')).toBe( 'https://example.com/case/metadata-base/url-string' ) }) }) describe('opengraph', () => { it('should support opengraph tags', async () => { const browser = await next.browser('/opengraph') const matchMultiDom = createMultiDomMatcher(browser) await matchMultiDom('meta', 'property', 'content', { 'og:title': 'My custom title', 'og:description': 'My custom description', 'og:url': 'https://example.com', 'og:site_name': 'My custom site name', 'og:locale': 'en-US', 'og:type': 'website', 'og:image': [ 'https://example.com/image.png', 'https://example.com/image2.png', ], 'og:image:width': ['800', '1800'], 'og:image:height': ['600', '1600'], 'og:image:alt': 'My custom alt', 'og:video': 'https://example.com/video.mp4', 'og:video:width': '800', 'og:video:height': '450', 'og:audio': 'https://example.com/audio.mp3', }) await matchMultiDom('meta', 'name', 'content', { 'twitter:card': 'summary_large_image', 'twitter:title': 'My custom title', 'twitter:description': 'My custom description', 'twitter:image': [ 'https://example.com/image.png', 'https://example.com/image2.png', ], 'twitter:image:width': ['800', '1800'], 'twitter:image:height': ['600', '1600'], 'twitter:image:alt': 'My custom alt', }) }) it('should support opengraph with article type', async () => { const browser = await next.browser('/opengraph/article') const matchMultiDom = createMultiDomMatcher(browser) await matchMultiDom('meta', 'property', 'content', { 'og:title': 'My custom title | Layout open graph title', 'og:description': 'My custom description', 'og:type': 'article', 'og:image': 'https://example.com/og-image.jpg', 'og:email': 'author@vercel.com', 'og:phone_number': '1234567890', 'og:fax_number': '1234567890', 'article:published_time': '2023-01-01T00:00:00.000Z', 'article:author': ['author1', 'author2', 'author3'], }) }) it('should pick up opengraph-image and twitter-image as static metadata files', async () => { const $ = await next.render$('/opengraph/static') const match = createMultiHtmlMatcher($) match('meta', 'property', 'content', { 'og:image:width': '114', 'og:image:height': '114', 'og:image:type': 'image/png', 'og:image:alt': 'A alt txt for og', 'og:image': isNextDev ? expect.stringMatching( /http:\/\/localhost:\d+\/opengraph\/static\/opengraph-image/ ) : expect.stringMatching( new RegExp( `https:\\/\\/(${ isNextDeploy ? '[^/]+' : 'example\\.com' })\\/opengraph\\/static\\/opengraph-image` ) ), }) match('meta', 'name', 'content', { 'twitter:image': isNextDev ? expect.stringMatching( /http:\/\/localhost:\d+\/opengraph\/static\/twitter-image/ ) : expect.stringMatching( new RegExp( `https:\\/\\/(${ isNextDeploy ? '[^/]+' : 'example\\.com' })\\/opengraph\\/static\\/twitter-image` ) ), 'twitter:image:alt': 'A alt txt for twitter', 'twitter:card': 'summary_large_image', }) // favicon shouldn't be overridden expect($('link[rel="icon"]').attr('href')).toMatch(FAVICON_REGEX) }) it('should override file based images when opengraph-image and twitter-image specify images property', async () => { const $ = await next.render$('/opengraph/static/override') const match = createMultiHtmlMatcher($) match('meta', 'property', 'content', { 'og:title': 'no-og-image', 'og:image': undefined, }) match('meta', 'name', 'content', { 'twitter:image': undefined, 'twitter:title': 'no-tw-image', }) // icon should be overridden and contain favicon.ico const [favicon, ...icons] = $('link[rel="icon"]') .toArray() .map((i) => $(i).attr('href')) expect(favicon).toMatch(FAVICON_REGEX) expect(icons).toEqual(['https://custom-icon-1.png']) }) it('metadataBase should override fallback base for resolving OG images', async () => { const browser = await next.browser('/metadata-base/opengraph') const matchMultiDom = createMultiDomMatcher(browser) await matchMultiDom('meta', 'property', 'content', { 'og:image': 'https://acme.com/og-image.png', }) }) }) describe('icons', () => { it('should support basic object icons field', async () => { const browser = await next.browser('/icons') await checkLink(browser, 'shortcut icon', '/shortcut-icon.png') await checkLink(browser, 'icon', '/icon.png') await checkLink(browser, 'apple-touch-icon', '/apple-icon.png') await checkLink(browser, 'other-touch-icon', '/other-touch-icon.png') }) it('should support basic string icons field', async () => { const browser = await next.browser('/icons/string') await checkLink(browser, 'icon', '/icon.png') }) it('should support basic complex descriptor icons field', async () => { const browser = await next.browser('/icons/descriptor') const matchDom = createDomMatcher(browser) await checkLink(browser, 'shortcut icon', '/shortcut-icon.png') await checkLink(browser, 'icon', [ expect.stringMatching(FAVICON_REGEX), '/icon.png', 'https://example.com/icon.png', ]) await checkLink(browser, 'apple-touch-icon', [ '/icon2.png', '/apple-icon.png', '/apple-icon-x3.png', ]) await checkLink(browser, 'other-touch-icon', '/other-touch-icon.png') await matchDom('link', 'href="/apple-icon-x3.png"', { sizes: '180x180', type: 'image/png', }) }) it('should merge icons from layout if no static icons files are specified', async () => { const browser = await next.browser('/icons/descriptor/from-layout') const matchDom = createDomMatcher(browser) await matchDom('link', 'href="favicon-light.png"', { media: '(prefers-color-scheme: light)', }) await matchDom('link', 'href="favicon-dark.png"', { media: '(prefers-color-scheme: dark)', }) }) it('should not hoist meta[itemProp] to head', async () => { const $ = await next.render$('/') expect($('head meta[itemProp]').length).toBe(0) expect($('header meta[itemProp]').length).toBe(1) }) it('should support root level of favicon.ico', async () => { let $ = await next.render$('/') const favIcon = $('link[rel="icon"]') expect(favIcon.attr('href')).toMatch(FAVICON_REGEX) expect(favIcon.attr('type')).toBe('image/x-icon') // Turbopack renders / emits image differently expect(['16x16', '48x48']).toContain(favIcon.attr('sizes')) const iconSvg = $('link[rel="icon"][type="image/svg+xml"]') expect(iconSvg.attr('href')).toMatch('/icon.svg?') // Turbopack renders / emits image differently expect(['any', '48x48']).toContain(iconSvg.attr('sizes')) $ = await next.render$('/basic') const icon = $('link[rel="icon"]') expect(icon.attr('href')).toMatch(FAVICON_REGEX) expect(['16x16', '48x48']).toContain(favIcon.attr('sizes')) if (!isNextDeploy) { const faviconFileBuffer = await fs.readFile( path.join(next.testDir, 'app/favicon.ico') ) const faviconResponse = Buffer.from( await next.fetch('/favicon.ico').then((res) => res.arrayBuffer()) ) return expect(Buffer.compare(faviconResponse, faviconFileBuffer)).toBe( 0 ) } }) }) describe('file based icons', () => { it('should render icon and apple touch icon meta if their images are specified', async () => { const $ = await next.render$('/icons/static/nested') const $icon = $('link[rel="icon"][type!="image/x-icon"]') const $appleIcon = $('link[rel="apple-touch-icon"]') expect($icon.attr('href')).toMatch(/\/icons\/static\/nested\/icon1/) expect($icon.attr('sizes')).toBe('32x32') expect($icon.attr('type')).toBe('image/png') expect($appleIcon.attr('href')).toMatch( /\/icons\/static\/nested\/apple-icon/ ) expect($appleIcon.attr('type')).toBe('image/png') expect($appleIcon.attr('sizes')).toMatch('114x114') }) it('should not render if image file is not specified', async () => { const $ = await next.render$('/icons/static') const $icon = $('link[rel="icon"][type!="image/x-icon"]') expect($icon.attr('href')).toMatch(/\/icons\/static\/icon/) expect($icon.attr('sizes')).toBe('114x114') // No apple icon if it's not provided const $appleIcon = $('link[rel="apple-touch-icon"]') expect($appleIcon.length).toBe(0) const $dynamic = await next.render$('/icons/static/dynamic-routes/123') const $dynamicIcon = $dynamic('link[rel="icon"][type!="image/x-icon"]') const dynamicIconHref = $dynamicIcon.attr('href') // Static icon files under dynamic routes use "-" as placeholder // since the file content is the same regardless of params expect(dynamicIconHref).toMatch( /\/icons\/static\/dynamic-routes\/-\/icon/ ) const dynamicIconRes = await next.fetch(dynamicIconHref) expect(dynamicIconRes.status).toBe(200) }) }) describe('twitter', () => { it('should support twitter card summary_large_image when image present', async () => { const browser = await next.browser('/twitter') const matchMultiDom = createMultiDomMatcher(browser) await matchMultiDom('meta', 'name', 'content', { 'twitter:title': 'Twitter Title', 'twitter:description': 'Twitter Description', 'twitter:site:id': 'siteId', 'twitter:creator': 'creator', 'twitter:creator:id': 'creatorId', 'twitter:image': 'https://twitter.com/image.png', 'twitter:image:secure_url': 'https://twitter.com/secure.png', 'twitter:card': 'summary_large_image', }) }) it('should render twitter card summary when image is not present', async () => { const browser = await next.browser('/twitter/no-image') const matchMultiDom = createMultiDomMatcher(browser) await matchMultiDom('meta', 'name', 'content', { 'twitter:title': 'Twitter Title', 'twitter:description': 'Twitter Description', 'twitter:site:id': 'siteId', 'twitter:creator': 'creator', 'twitter:creator:id': 'creatorId', 'twitter:card': 'summary', }) }) it('should support default twitter player card', async () => { const browser = await next.browser('/twitter/player') const matchMultiDom = createMultiDomMatcher(browser) await matchMultiDom('meta', 'name', 'content', { 'twitter:title': 'Twitter Title', 'twitter:description': 'Twitter Description', 'twitter:site:id': 'siteId', 'twitter:creator': 'creator', 'twitter:creator:id': 'creatorId', 'twitter:image': 'https://twitter.com/image.png', // player properties 'twitter:card': 'player', 'twitter:player': 'https://twitter.com/player', 'twitter:player:stream': 'https://twitter.com/stream', 'twitter:player:width': '100', 'twitter:player:height': '100', }) }) it('should support default twitter app card', async () => { const browser = await next.browser('/twitter/app') const matchMultiDom = createMultiDomMatcher(browser) await matchMultiDom('meta', 'name', 'content', { 'twitter:title': 'Twitter Title', 'twitter:description': 'Twitter Description', 'twitter:site:id': 'siteId', 'twitter:creator': 'creator', 'twitter:creator:id': 'creatorId', 'twitter:image': [ 'https://twitter.com/image-100x100.png', 'https://twitter.com/image-200x200.png', ], // app properties 'twitter:card': 'app', 'twitter:app:id:iphone': 'twitter_app://iphone', 'twitter:app:id:ipad': 'twitter_app://ipad', 'twitter:app:id:googleplay': 'twitter_app://googleplay', 'twitter:app:url:iphone': 'https://iphone_url', 'twitter:app:url:ipad': 'https://ipad_url', 'twitter:app:url:googleplay': undefined, }) }) }) describe('static routes', () => { it('should have /favicon.ico as route', async () => { const res = await next.fetch('/favicon.ico') expect(res.status).toBe(200) expect(res.headers.get('content-type')).toBe('image/x-icon') expect(res.headers.get('cache-control')).toBe( isNextDev ? 'no-store, must-revalidate' : 'public, max-age=0, must-revalidate' ) }) it('should have icons as route', async () => { const resIcon = await next.fetch('/icons/static/icon.png') const resAppleIcon = await next.fetch( '/icons/static/nested/apple-icon.png' ) expect(resAppleIcon.status).toBe(200) expect(resAppleIcon.headers.get('content-type')).toBe('image/png') expect(resAppleIcon.headers.get('cache-control')).toBe( isNextDev ? 'no-store, must-revalidate' : 'public, max-age=0, must-revalidate' ) expect(resIcon.status).toBe(200) expect(resIcon.headers.get('content-type')).toBe('image/png') expect(resIcon.headers.get('cache-control')).toBe( isNextDev ? 'no-store, must-revalidate' : 'public, max-age=0, must-revalidate' ) }) it('should support root dir robots.txt', async () => { const res = await next.fetch('/robots.txt') expect(res.headers.get('content-type')).toBe( // In dev, sendStatic() is used to send static files, which adds MIME type. isNextDev ? 'text/plain; charset=UTF-8' : 'text/plain' ) expect(await res.text()).toContain('User-Agent: *\nDisallow:') const invalidRobotsResponse = await next.fetch('/title/robots.txt') expect(invalidRobotsResponse.status).toBe(404) }) it('should support sitemap.xml under every routes', async () => { const res = await next.fetch('/sitemap.xml') expect(res.headers.get('content-type')).toBe('application/xml') const sitemap = await res.text() expect(sitemap).toContain('') expect(sitemap).toContain( '' ) const invalidSitemapResponse = await next.fetch('/title/sitemap.xml') expect(invalidSitemapResponse.status).toBe(200) }) it('should support static manifest.webmanifest', async () => { const res = await next.fetch('/manifest.webmanifest') expect(res.headers.get('content-type')).toBe('application/manifest+json') const manifest = await res.json() expect(manifest).toMatchObject({ name: 'Next.js Static Manifest', short_name: 'Next.js App', description: 'Next.js App', start_url: '/', display: 'standalone', background_color: '#fff', theme_color: '#fff', }) }) if (isNextStart) { it('should build favicon.ico as a custom route', async () => { const appPathsManifest = JSON.parse( await next.readFile('.next/server/app-paths-manifest.json') ) expect(appPathsManifest['/robots.txt/route']).toBe( 'app/robots.txt/route.js' ) expect(appPathsManifest['/sitemap.xml/route']).toBe( 'app/sitemap.xml/route.js' ) }) } }) if (isNextStart) { describe('static optimization', () => { it('should build static files into static route', async () => { expect( await next.hasFile( '.next/server/app/opengraph/static/opengraph-image.png.meta' ) ).toBe(true) expect( await next.hasFile( '.next/server/app/opengraph/static/opengraph-image.png.body' ) ).toBe(true) expect( await next.hasFile( '.next/server/app/opengraph/static/opengraph-image.png/[__metadata_id__]/route.js' ) ).toBe(false) }) }) } describe('viewport', () => { it('should support dynamic viewport export', async () => { const browser = await next.browser('/viewport') const matchMultiDom = createMultiDomMatcher(browser) await matchMultiDom('meta', 'name', 'content', { 'theme-color': '#000', }) }) it('should skip initial-scale from viewport if it is set to undefined', async () => { const browser = await next.browser('/viewport/skip-initial-scale') const matchMultiDom = createMultiDomMatcher(browser) await matchMultiDom('meta', 'name', 'content', { viewport: 'width=device-width', }) }) }) describe('react cache', () => { it('should have same title and page value on initial load', async () => { const browser = await next.browser('/cache-deduping') const value = await browser.elementByCss('#value').text() const value2 = await browser.elementByCss('#value2').text() // Value in the title should match what's shown on the page component const title = await browser.eval(`document.title`) const obj = JSON.parse(title) // Check `cache()` expect(obj.val.toString()).toBe(value) // Check `fetch()` // TODO-APP: Investigate why fetch deduping doesn't apply but cache() does. if (!isNextDev) { expect(obj.val2.toString()).toBe(value2) } }) it('should have same title and page value when navigating', async () => { const browser = await next.browser('/cache-deduping/navigating') await browser .elementByCss('#link-to-deduping-page') .click() .waitForElementByCss('#value') const value = await browser.elementByCss('#value').text() const value2 = await browser.elementByCss('#value2').text() // Dynamic metadata streams in async await retry(async () => { expect(await browser.eval(`document.title`)).toContain( '"page":"cache-deduping"' ) }) // Value in the title should match what's shown on the page component const title = await browser.eval(`document.title`) const obj = JSON.parse(title) // Check `cache()` expect(obj.val.toString()).toBe(value) // Check `fetch()` // TODO-APP: Investigate why fetch deduping doesn't apply but cache() does. if (!isNextDev) { expect(obj.val2.toString()).toBe(value2) } }) }) it('should not effect metadata images convention like files under pages directory', async () => { const iconHtml = await next.render('/blog/icon') const ogHtml = await next.render('/blog/opengraph-image') expect(iconHtml).toContain('pages-icon-page') expect(ogHtml).toContain('pages-opengraph-image-page') }) 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.IS_TURBOPACK_TEST ? 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 = $('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' ) } ) } }) it('regression: renders a large shell', async () => { const pageErrors: unknown[] = [] await next.browser('/large-shell/foo', { beforePageLoad(page) { page.on('pageerror', (error) => { pageErrors.push(error) }) }, }) // TODO: Assert on errorless pages by default. // This isn't 100% accurate. // We sometimes receive the pageerror after the hydration complete event // since that event is just for shell hydration not everything being hydrated. expect(pageErrors).toEqual([]) }) })